- Framework:Next.js app router
- UI:Tailwind + headlessui
- Database & ORM:prisma + mongodb
- Hashed password:bcrypt
- Form:react-hook-form
- Third-Party Login:NextAuth
- Upload Image:next-cloudinary
- Realtime Conversation: Pusher
如果要讓 Next 載入其他網域的圖片,需要到 next.config.js
配置,如下
const nextConfig = {
images: {
domains: [
'res.cloudinary.com', // 指定 Cloudinary 網域的圖片可以被載入和顯示
'avatars.githubusercontent.com', // 指定 GitHub 使用者頭像的網域的圖片可以被載入和顯示
'lh3.googleusercontent.com', // 指定 Google 使用者頭像的網域的圖片可以被載入和顯示
],
},
};
module.exports = nextConfig;
❗ 注意,Next 14 已經改用 remotePattern
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
port: '',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
port: '',
},
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
port: '',
}
],
},
};
module.exports = nextConfig;
- 資訊回應 (Informational responses, 100–199)
- 成功回應 (Successful responses, 200–299)
- 200 OK 請求成功。
- 重定向 (Redirects, 300–399)
- 用戶端錯誤 (Client errors, 400–499)
- 400 Bad Request 此回應意味伺服器因為收到無效語法,而無法理解請求。
- 401 Unauthorized 需要授權以回應請求。它有點像 403,但這裡的授權,是有可能辦到的。
- 403 Forbidden 用戶端並無訪問權限,例如未被授權,所以伺服器拒絕給予應有的回應。不同於 401,伺服端知道用戶端的身份。
- 404 Not Found 伺服器找不到請求的資源。
- 伺服器端錯誤 (Server errors, 500–599)
- 500 Internal Server Error 伺服器端發生未知或無法處理的錯誤。
- 504 Gateway Timeout 伺服器作為閘道器時無法及時得到回應。
可以利用表驅動法 (Table-Driven Approach) 來針對多個元素的樣式
// app\components\AvatarGroup.tsx`
const AvatarGroup = ({ users = [] }) => {
// ...
// Here 👇
const positionMap = {
0: 'top-0 left-[12px]',
1: 'bottom-0',
2: 'bottom-0 right-0',
};
return (
// ...
{slicedUsers.map((user, index) => (
<div
key={user.id}
className={`absolute inline-block rounded-full overflow-hidden h-[21px] w-[21px] ${
positionMap[index as keyof typeof positionMap] // Here 👈
}`}
>
// ...
</div>
))}
);
};
export default AvatarGroup;
透過 Transition 和 Dialog 可以封裝自己的 Modal。
// app\components\Modal.tsx
'use client';
// Components
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { IoClose } from 'react-icons/io5';
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, children }) => {
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
{/* Background overlay */}
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
</Transition.Child>
{/* Modal container */}
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="fixed inset-0 z-10 overflow-y-auto min-h-full flex items-center justify-center p-4 text-center sm:p-0">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 text-left shadow-xl transition-all w-full sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
{/* Close button */}
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block z-10">
<button
type="button"
className="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
onClick={onClose}
>
<span className="sr-only">Close</span>
<IoClose className="h-6 w-6"></IoClose>
</button>
</div>
{/* Modal content */}
{children}
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition.Root>
);
};
export default Modal;
接著可以在需要的地方補充更多功能,例如 ImageModal
// Components
import Modal from '@/app/components/Modal';
import Image from 'next/image';
export const ImageModal: React.FC<ImageModalProps> = ({
isOpen,
onClose,
src,
}) => {
if (!src) {
return null;
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<div className="w-80 h-80">
<Image
alt="Image"
fill
src={src}
className="object-cover"
/>
</div>
</Modal>
);
};
此時可以需要用 Modal 的旁邊引入 ImageModal,而不是在組件的最上層,這樣利於維護。
// app\conversations\[conversationId]\components\MessageBox.tsx
const MessageBox: React.FC<MessageBoxProps> = ({ data, isLast }) => {
// ...
return (
// ...
// Here 👇
<ImageModal
src={data.image}
isOpen={imageModalOpen}
onClose={() => setImageModalOpen(false)}
/>
{data.image ? (
<Image
onClick={() => setImageModalOpen(true)}
alt="Image"
height={288}
width={288}
src={data.image}
className="object-cover cursor-pointer hover:scale-110 transition translate"
/>
) : (
<div>{data.body}</div>
)}
// ...
);
};
export default MessageBox;
在 Server 端,這裡是 node.js,使用 pusher 在一個 channels(渠道)來發布 event(事件)
import {pusherServer} from 'pusher'
pusher.trigger('my-channel', 'my-event', data, socketId).catch((error) => {
console.log(error);
});
接著在 Client 端使用 pusher-js 來訂閱並綁定事件
import {pusherClient} from 'pusher-js'
pusherClient.subscribe('my-channel');
// ...
pusherClient.bind('my-event', fallback)
在 app/api/auth/[...nextauth]/route.ts
裡設定 NextAuth。
如果你除了第三方登陸以外,還希望用戶可以建立帳號在你的資料庫,並執行登陸的話,可以用 CredentialsProvider
import bcrypt from 'bcrypt';
import NextAuth, { AuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GithubProvider from 'next-auth/providers/github';
import GoogleProvider from 'next-auth/providers/google';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import prisma from '@/app/libs/prismadb';
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID as string,
clientSecret: process.env.GITHUB_SECRET as string,
}),
GoogleProvider({
clientId: process.env.GOOGLE_ID as string,
clientSecret: process.env.GOOGLE_SECRET as string,
}),
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'email', type: 'text' },
password: { label: 'password', type: 'password' },
},
async authorize(credentials) {
// 這裡放認證的流程
if (!credentials?.email || !credentials?.password) {
throw new Error('Invalid Credentials');
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email,
},
});
if (!user || !user.hashedPassword) {
throw new Error('Invalid Credentials');
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.hashedPassword
);
if (!isCorrectPassword) {
throw new Error('Invalid credentials');
}
return user;
},
}),
],
debug: process.env.NODE_ENV === 'development',
session: {
strategy: 'jwt',
},
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
接著就能在登錄頁面處理登陸或註冊的邏輯。
而 NextAuth 很方便,要登入的話直接調用 signIn 即可。也可以用 useSession 來查看用戶的狀態 (但要記得在父組件提供 SessionProvider)
import { signIn, useSession } from 'next-auth/react';
// Type
type VariantType = 'LOGIN' | 'REGISTER';
const AuthForm = () => {
const [variant, setVariant] = useState<VariantType>('LOGIN');
const [isLoading, setIsLoading] = useState(false);
const session = useSession();
const router = useRouter();
// if user is login, jump to users page
useEffect(() => {
if (session?.status === 'authenticated') {
router.push('/users');
}
}, [session?.status]);
// ...
// Handle normal credentials log in
const onSubmit: SubmitHandler<FieldValues> = (data) => {
setIsLoading(true);
if (variant === 'REGISTER') {
axios
.post('/api/register', data)
.then(() => signIn('credentials', data))
.catch(() => toast.error('Something went wrong!'))
.finally(() => setIsLoading(false));
}
if (variant === 'LOGIN') {
signIn('credentials', {
...data,
redirect: false,
})
.then((callback) => {
if (callback?.error) {
toast.error('Invalid credentials');
}
if (callback?.ok && !callback?.error) {
toast.success('Logged in!');
router.push('/users');
}
})
.finally(() => setIsLoading(false));
}
};
// Handle github or google log in
const socialAction = (action: 'github' | 'google') => {
setIsLoading(true);
// NextAuth Social Sign In
signIn(action, { redirect: false })
.then((callback) => {
if (callback?.error) {
toast.error('Invalid Credentials');
}
if (callback?.ok && !callback?.error) {
toast.success('Logged in!');
router.push('/users');
}
})
.finally(() => setIsLoading(false));
};
return ( 'FORM UI' );
};
export default AuthForm;