지난 NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(서버편)에 이어서 클라이언트편을 이어갑니다.
서버 준비가 완료됐으니 이제 NextJS로 프론트엔드를 설정합니다.
터미널에서 다음 명령을 실행하여 새 NextJS 앱을 만듭니다.
npx create-next-app@latest
Tailwind CSS를 선택하는 것을 잊지 마세요. 개발 프로세스를 크게 가속하고 귀중한 시간을 절약해 줄 것입니다.
이제 필수 패키지를 설치합니다.
cd ui;
npm i -s @apollo/client graphql graphql-ws;
이제 프론트엔드를 구성해 볼텐데 파일 구조는 아래와 같습니다.
├── backend
├── ui
│ ├── src
│ │ ├── helpers
│ | | ├── gql.request.ts
│ | | ├── gql.setup.ts
│ │ ├── pages
│ | | ├── _app.tsx
│ | | ├── _document.tsx
│ | | ├── index.tsx
│ │ ├── styles
│ | | ├── globals.css
GraphQL 링크 설정
파일을 열고 helpers/gql.setup.ts 내용을 아래와 같이 변경하세요.
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client'
import { getMainDefinition } from '@apollo/client/utilities'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
/**
* @see https://www.apollographql.com/docs/react/data/subscriptions
*/
const httpGqlLink = new HttpLink({
uri: 'http://localhost:3001/graphql',
})
const wsGqlLink = new GraphQLWsLink(
createClient({
url: 'ws://localhost:3001/graphql',
})
)
// The split function takes three parameters:
//
// * A function that's called for each operation to execute
// * The Link to use for an operation if the function returns a "truthy" value
// * The Link to use for an operation if the function returns a "falsy" value
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
)
},
wsGqlLink,
httpGqlLink
)
export const mainGqlClient = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
})
GraphQL 요청 등록
파일을 열고 helpers/gql.request.ts 내용을 아래와 같이 변경하세요.
import { useQuery, gql, useMutation, useSubscription } from '@apollo/client'
import { mainGqlClient } from './gql.setup'
export type Nullable<T> = T | null | undefined
export interface IMessage {
id: string
conversationId: string
senderId: string
content: string
createdAt: Date
}
export interface IConversation {
id: string
name: string
messages: IMessage[]
lastMessage?: Nullable<IMessage>
createdAt: Date
}
export interface IConversationWithoutMessages
extends Omit<IConversation, 'messages'> {}
export const SEND_NEW_MESSAGE = gql`
mutation SendNewMessage(
$conversationId: ID!
$senderId: ID!
$content: String!
) {
messageCreate(
conversationId: $conversationId
senderId: $senderId
content: $content
) {
id
conversationId
content
createdAt
senderId
}
}
`
export const GET_CONVERSATION_DETAILS = gql`
query GetConversationDetails($id: ID!) {
conversationById(id: $id) {
id
name
createdAt
}
}
`
export const GET_CONVERSATION_MESSAGES = gql`
query GetConversationMessages($conversationId: ID!) {
conversationById(id: $conversationId) {
messages {
id
content
createdAt
senderId
}
}
}
`
export const GET_CONVERSATIONS_LIST = gql`
query GetConversationsList {
conversationsList {
id
name
createdAt
lastMessage {
id
senderId
content
createdAt
}
}
}
`
const NEW_MESSAGE_SENT_SUBSCRIPTION = gql`
subscription OnNewMessageSent($conversationId: ID!) {
onNewMessageSent(conversationId: $conversationId) {
content
conversationId
senderId
createdAt
id
}
}
`
export const getConversationDetails = (id: string) => {
const { data, loading, error, refetch } = useQuery(GET_CONVERSATION_DETAILS, {
variables: { id },
fetchPolicy: 'no-cache',
})
return {
conversation:
data?.conversationById as Nullable<IConversationWithoutMessages>,
loading,
error,
refetch,
}
}
export const getConversationMessages = (conversationId: string) => {
const { data, loading, error, refetch } = useQuery(
GET_CONVERSATION_MESSAGES,
{
variables: { conversationId },
fetchPolicy: 'no-cache',
}
)
return {
messages: data?.conversationById?.messages as IMessage[],
loading,
error,
refetch,
}
}
export const getConversationsList = () => {
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS_LIST, {
fetchPolicy: 'no-cache',
})
return {
conversations: data?.conversationsList as IConversationWithoutMessages[],
loading,
error,
refetch,
}
}
export const sendNewMessage = (
conversationId: string,
senderId: string,
content: string
) => {
const [createMessage, { data, loading, error }] = useMutation(
SEND_NEW_MESSAGE,
{
variables: { conversationId, senderId, content },
fetchPolicy: 'no-cache',
}
)
return {
createMessage,
message: data?.messageCreate as IMessage,
loading,
error,
}
}
export const subscribeToConversation = (conversationId: string) => {
const { data, loading, error } = useSubscription(
NEW_MESSAGE_SENT_SUBSCRIPTION,
{
variables: { conversationId },
fetchPolicy: 'no-cache',
}
)
return {
message: data?.onNewMessageSent as IMessage,
loading,
error,
}
}
export const sendNewMessageAsync = (
conversationId: string,
senderId: string,
content: string
) =>
mainGqlClient
.mutate({
mutation: SEND_NEW_MESSAGE,
variables: {
conversationId,
senderId,
content,
},
fetchPolicy: 'no-cache',
})
.then(({ data }) => {
return data?.messageCreate as IMessage
})
export const getConversationsListAsync = () =>
mainGqlClient
.query({
query: GET_CONVERSATIONS_LIST,
fetchPolicy: 'no-cache',
})
.then(({ data }) => {
return data?.conversationsList as IConversationWithoutMessages[]
})
export const getConversationDetailsAsync = (conversationId: string) =>
mainGqlClient
.query({
query: GET_CONVERSATION_DETAILS,
variables: {
conversationId,
},
fetchPolicy: 'no-cache',
})
.then(({ data }) => {
return data?.conversationById as IMessage[]
})
export const getConversationMessagesAsync = (conversationId: string) =>
mainGqlClient
.query({
query: GET_CONVERSATION_MESSAGES,
variables: {
conversationId,
},
fetchPolicy: 'no-cache',
})
.then(({ data }) => {
console.log({ data })
return data?.conversationById?.messages as IMessage[]
})
그런 다음 ApolloProvider 를 NextJS 앱에 등록해야 합니다. pages/_app.tsx
import '@/styles/globals.css'
import { ApolloProvider } from '@apollo/client'
import type { AppProps } from 'next/app'
import Head from 'next/head'
import { mainGqlClient } from './../helpers/gql.setup'
export default function App({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={mainGqlClient}>
<Component {...pageProps} />
<Head>
<title>
Demo Chat App - Built on top of NestJS & GraphQL, and NextJS
</title>
</Head>
</ApolloProvider>
)
}
이제 Apollo Client 를 사용해 GraphQL의 기본 설정을 완료했습니다.
다음으로 사용자를 위해 UI를 업데이트하겠습니다.
파일을 열고 pages/index.tsx 내용을 아래 코드로 바꿉니다.
import { useEffect, useState } from 'react'
import {
getConversationMessagesAsync,
getConversationsList,
IConversationWithoutMessages,
IMessage,
Nullable,
sendNewMessageAsync,
subscribeToConversation,
} from './helpers/gql.request'
const demoUserId = 'Qo952C9SB2RtXc0Qtrx'
export default function Home() {
return (
<div className="shadow-lg rounded-lg flex-1 relative flex flex-col">
<div className="px-5 py-5 flex justify-between items-center bg-white border-b-2">
<div className="font-bold text-2xl">Demo Chat App</div>
<div className="w-1/2 text-center text-xl font-semibold">
Built on top of NestJS & GraphQL, and NextJS
</div>
<div className="h-12 font-semibold flex items-center justify-center">
<AvatarItem className="w-12 rounded-full mr-2" />
<span className="whitespace-nowrap">Justin Phan</span>
</div>
</div>
<ConversationsBox />
</div>
)
}
const ConversationsBox = () => {
const { conversations, loading } = getConversationsList()
const [current, setCurrent] =
useState<Nullable<IConversationWithoutMessages>>(null)
useEffect(() => {
if (!loading) {
setCurrent(conversations[0])
}
}, [conversations])
return (
<div className="flex-1 flex flex-row justify-between bg-white">
<div className="flex flex-col w-2/5 border-r-2 overflow-y-auto">
<ConversationsList
conversations={conversations || []}
currentConversationId={current?.id}
onSelectItem={(item) => {
setCurrent(item)
}}
/>
</div>
{current && (
<ConversationDetailsBox
conversation={current}
onNewMessageSent={(msg) => {
setCurrent({
...current,
lastMessage: msg,
})
}}
/>
)}
</div>
)
}
const ConversationsList = (props: {
conversations: IConversationWithoutMessages[]
currentConversationId: Nullable<string>
onSelectItem: (item: IConversationWithoutMessages) => void
}) => {
return (
<>
{props.conversations.map((item) => (
<div
key={item.id}
onClick={() => {
props.onSelectItem(item)
}}
className={`flex flex-row cursor-pointer ease-in-out duration-300 hover:bg-gray-200 py-4 px-2 justify-center items-center border-b-2 border-l-4 ${
props.currentConversationId === item.id
? 'border-blue-400 bg-gray-100'
: ''
}`}
>
<div className="w-1/4">
<AvatarItem
className="object-cover h-12 w-12 rounded-full"
isMySelf={false}
/>
</div>
<div className="d-flex w-full overflow-hidden">
<div className="text-lg font-semibold">{item.name}</div>
</div>
</div>
))}
</>
)
}
const ConversationDetailsBox = (props: {
conversation: IConversationWithoutMessages
onNewMessageSent: (newMessage: IMessage) => void
}) => {
const [messages, setMessages] = useState<IMessage[]>([])
const { message: newMessage } = subscribeToConversation(props.conversation.id)
useEffect(() => {
if (newMessage) {
setMessages([...messages, newMessage])
}
}, [newMessage])
useEffect(() => {
fetchMessages()
}, [props.conversation.id])
function fetchMessages() {
getConversationMessagesAsync(props.conversation.id).then((res) => {
setMessages(res)
})
}
return (
<div className="w-full px-5 flex flex-col justify-between">
<div className="pt-3 text text-xs">
<span>
<span>Chat ID: </span>
<span className="text-red-500">{props.conversation.id}</span>
</span>
<br />
<span>
<span>Your user ID: </span>
<span className="text-red-500">{demoUserId}</span>
</span>
</div>
<div className="flex flex-col mt-5">
{!!messages &&
messages.map((msg) => (
<MessageItem
key={msg.id}
isMySelf={`${msg.senderId}` === demoUserId}
message={msg}
/>
))}
</div>
<div className="mt-auto pb-5 w-full">
<form
method="POST"
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const content = formData.get('content')?.toString()
const input = document.getElementById(
'input_new_message'
) as HTMLInputElement
if (content) {
sendNewMessageAsync(
props.conversation.id,
demoUserId,
content
).then((res) => {
// props.onNewMessageSent(res)
})
input.value = ''
}
}}
className="flex-1 flex flex-row justify-between bg-white"
>
<input
required
name="content"
id="input_new_message"
className="w-full bg-gray-300 py-5 px-3 rounded-xl"
type="text"
placeholder="type your message here..."
autoComplete="off"
/>
</form>
</div>
</div>
)
}
const MessageItem = ({
isMySelf,
message,
}: {
isMySelf: boolean
message: IMessage
}) => {
if (!message) return
return (
<div className={`flex ${isMySelf ? 'justify-end' : 'justify-start'} mb-4`}>
<div
className={`${
isMySelf
? 'mr-2 bg-blue-400 rounded-bl-3xl rounded-tl-3xl rounded-tr-xl'
: 'order-1 ml-2 bg-gray-400 rounded-br-3xl rounded-tr-3xl rounded-tl-xl'
} text-white py-3 px-4`}
>
{message.content}
</div>
<AvatarItem
className="object-cover h-8 w-8 rounded-full"
isMySelf={isMySelf}
/>
</div>
)
}
const AvatarItem = ({
className,
isMySelf = true,
}: {
className: string
isMySelf?: boolean
}) => {
return (
<img
src={
isMySelf
? 'https://i.pinimg.com/564x/28/64/a9/2864a9a9976a30753c3af93f4597d513.jpg'
: 'https://i.pinimg.com/564x/a1/06/d9/a106d9a78d1190cb521d39ec938f4033.jpg'
}
className={className}
alt=""
/>
)
}
앱이 UI를 개선하기 위해 전역 스타일을 업데이트합니다.
// styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
#__next {
min-width: 100vw;
min-height: 100vh;
display: flex;
flex-flow: column nowrap;
}
이제 아래 명령을 실행하여 프론트엔드를 시작하겠습니다.
npm run dev;
> ui@0.1.0 dev
> next dev
- ready started server on 0.0.0.0:3000, url: http://localhost:3000
- event compiled client and server successfully in 103 ms (18 modules)
- wait compiling...
- event compiled client and server successfully in 72 ms (18 modules)
- wait compiling / (client and server)...
- event compiled client and server successfully in 715 ms (442 modules)
- wait compiling...
- event compiled client and server successfully in 108 ms (442 modules)
" http://localhost:3000 " 링크를 열면 아래 UI가 표시됩니다.
2개 이상의 서로 다른 브라우저 탭에서 채팅을 열 수 있으며, 보내는 모든 메시지는 모든 탭에 즉시 표시됩니다.
지난 NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(서버편)에 이어서 2편 NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(클라이언트편)으로 nestjs, fastify, apollo graphql, nextjs 기술을 활용해 간단하게 채팅앱 구현에 대해 알아봤습니다. 따라오느라 수고하셨습니다!
paramiko ssh stdin.write 명령어 사용 시 OSError: socket is closed 발생 (0) | 2024.07.02 |
---|---|
🏆 2024년 4월 가장 인기 있는 필수 솔루션 10개 (0) | 2024.05.24 |
NestJS, Fastify, Apollo GraphQL & NextJS를 사용해 채팅 앱 구축(서버편) (0) | 2024.05.21 |
벡터 검색을 통한 검색 혁명 Alloy DB (0) | 2024.05.17 |
MSSQL(SQL Server) 2022 설치 및 구성 방법 (0) | 2024.05.04 |
댓글 영역