Skip to content

Commit 19402d8

Browse files
authored
Merge pull request #35 from SWMTheFirstTake/dev
Dev
2 parents 40cca59 + 9eed999 commit 19402d8

File tree

12 files changed

+138
-79
lines changed

12 files changed

+138
-79
lines changed

app/layout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { Provider as JotaiProvider } from 'jotai';
55
import QueryProvider from 'app/providers';
66
import { GoogleAnalytics } from '@next/third-parties/google';
77
import AuthJotaiInitializer from '@/components/auth/AuthJotaiInitializer';
8+
import { Suspense } from 'react';
9+
import WebVitals from '@/components/WebVitals';
810

911
// const geistSans = Geist({
1012
// variable: '--font-geist-sans',
@@ -95,8 +97,11 @@ export default function RootLayout({
9597
</QueryProvider>
9698
</JotaiProvider>
9799
</main>
98-
<GoogleAnalytics gaId="G-LS5SN8G0F6" />
100+
<Suspense fallback={null}>
101+
<WebVitals />
102+
</Suspense>
99103
</body>
104+
<GoogleAnalytics gaId="G-LS5SN8G0F6" />
100105
</html>
101106
);
102107
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"react": "^19.0.0",
4949
"react-dom": "^19.0.0",
5050
"tailwind-merge": "^3.3.1",
51+
"web-vitals": "^5.1.0",
5152
"zustand": "^5.0.4"
5253
},
5354
"devDependencies": {

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/chatAPI.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import { requestAPI } from '@/api/API';
22

3+
type APIMessage = {
4+
id: number;
5+
content: string;
6+
image_url: string | null;
7+
message_type: string | 'USER';
8+
created_at: string;
9+
agent_type: string | null;
10+
agent_name: string | null;
11+
product_image_url: string[];
12+
};
13+
314
const formatfromAPIMessagetoMessage = (apiMsg: APIMessage): Message => {
415
let message: Message;
516
const imageUrls: MessageImage[] = apiMsg.product_image_url
6-
? [
7-
{
8-
src: apiMsg.product_image_url,
9-
name: '얇은 비키니',
10-
content:
11-
'몰라요, 그냥 일단 비키니라고 예시를 넣었는데, 만약 이 글을 발견했다면 프론트엔드 개발자한테 chatAPI.ts 수정하라고 하세요',
12-
tags: ['섹시한', '도발적인', '노출이심한', '과감한', '매력적인'],
13-
},
14-
]
17+
? apiMsg.product_image_url.map((imageUrl) => ({
18+
src: imageUrl,
19+
name: '얇은 비키니',
20+
content:
21+
'몰라요, 그냥 일단 비키니라고 예시를 넣었는데, 만약 이 글을 발견했다면 프론트엔드 개발자한테 chatAPI.ts 수정하라고 하세요',
22+
tags: ['섹시한', '도발적인', '노출이심한', '과감한', '매력적인'],
23+
}))
1524
: [];
1625

1726
if (apiMsg.message_type === 'USER') {

src/atoms/chatAtoms.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { atomFamily } from 'jotai/utils';
44
export const inputValueAtom = atom<string>('');
55
export const inputImageAtom = atom<MessageImage | undefined>(undefined);
66
export const currentChatIdAtom = atom<number | null>(null);
7-
export const isAIRespondingAtom = atom<boolean>(false);
8-
export const hasUserSentMessageAtom = atom<boolean>(false);
7+
export const isAIRespondingAtom = atom<'style' | 'trend' | 'color' | 'codi' | ''>('');
98
export const isSidebarOpenAtom = atom<boolean>(false);
109

1110
export const activePanelTypeAtom = atom<'image' | 'wiki' | null>(null);

src/components/WebVitals.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { onCLS, onINP, onLCP, onFCP, onTTFB, Metric } from 'web-vitals';
5+
6+
// 이 함수는 window.gtag가 이미 로드되었다고 가정하고 이벤트를 보냅니다.
7+
function sendToGoogleAnalytics({ name, delta, value, id }: Metric) {
8+
if (typeof window.gtag === 'function') {
9+
window.gtag('event', name, {
10+
value: delta,
11+
metric_id: id,
12+
metric_value: value,
13+
metric_delta: delta,
14+
});
15+
}
16+
}
17+
18+
export default function WebVitals() {
19+
// 이 컴포넌트는 이제 web-vitals 측정 및 전송 로직만 담당합니다.
20+
useEffect(() => {
21+
onCLS(sendToGoogleAnalytics);
22+
onINP(sendToGoogleAnalytics);
23+
onLCP(sendToGoogleAnalytics);
24+
onFCP(sendToGoogleAnalytics);
25+
onTTFB(sendToGoogleAnalytics);
26+
}, []);
27+
28+
return null; // 이 컴포넌트는 화면에 아무것도 렌더링하지 않습니다.
29+
}

src/components/chat/AILoadingSpinner.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
1+
import { isAIRespondingAtom } from '@/atoms/chatAtoms';
2+
import { messageColor } from '@/styles/chat';
3+
import { useAtomValue } from 'jotai';
4+
15
export default // AI 응답 준비 중 스피너 컴포넌트
26
function AILoadingSpinner() {
7+
const isAIResponding = useAtomValue(isAIRespondingAtom);
8+
const agentnameParser = () => {
9+
if (isAIResponding == 'style') return '스타일 분석가';
10+
if (isAIResponding == 'trend') return '트랜드 전문가';
11+
if (isAIResponding == 'color') return '컬러 전문가';
12+
if (isAIResponding == 'codi') return '핏팅 코디네이터';
13+
};
314
return (
415
<>
516
{/* 첫 번째 AI 스피너 */}
17+
618
<div className="flex justify-start">
7-
<div className="max-w-[70%] p-6 rounded-lg bg-gray-100 dark:bg-gray-700">
19+
<div className="w-12 h-12 m-4 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 font-bold text-lg">
20+
AI
21+
</div>
22+
<div className={`max-w-[70%] p-6 rounded-lg ${messageColor[1]}`}>
23+
<p className="text-lg md:text-xl font-serif font-extrabold mb-3">{agentnameParser()}</p>
824
<div className="flex space-x-2">
925
<div
1026
className="w-3 h-3 bg-blue-500 rounded-full animate-pulse"

src/components/chat/ChatArea.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import ExampleSuggestions from '@/components/chat/ExampleSuggestion';
88
import MessageBalloon from '@/components/chat/MessageBalloon';
99
import { useChatMessage } from '@/queries/useChatMessage';
1010
import { useAtomValue } from 'jotai';
11-
import { useCallback, useEffect, useRef } from 'react';
11+
import { useRef } from 'react';
1212

1313
export default function ChatArea() {
1414
const isAIResponding = useAtomValue(isAIRespondingAtom);

src/components/chat/ChatContextProvider.tsx

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
inputImageAtom,
77
currentChatIdAtom,
88
isAIRespondingAtom,
9-
hasUserSentMessageAtom,
109
activePanelTypeAtom,
1110
imageTabsAtom,
1211
wikiTabsAtom,
@@ -15,19 +14,20 @@ import {
1514
isSidebarOpenAtom,
1615
messagesAtomFamily,
1716
} from '@/atoms/chatAtoms';
18-
import { createContext, ReactNode, useCallback, useContext, useEffect } from 'react';
17+
import { ChangeEvent, createContext, ReactNode, useCallback, useContext, useEffect } from 'react';
1918
import { useChatRooms } from '@/queries/useChatRoom';
2019
import { useChatMessage } from '@/queries/useChatMessage';
2120
import { userAtom } from '@/atoms/authAtoms';
2221
import { tmpUserId, tmpUsername } from '@/queries/useUser';
23-
import { getChatRoomsRoomIdMessages } from '@/api/chatAPI';
22+
import { postChatUpload, getChatRoomsRoomIdMessages } from '@/api/chatAPI';
2423

2524
type ChatActionsContextType = {
2625
handleSendMessage: () => void;
2726
handleNewChat: () => void;
2827
handleChatSelect: (chatId: number) => void;
2928
handleExampleSelect: (exampleText: string) => void;
3029
handleOpenTab: (data: any, type: 'image' | 'wiki') => void;
30+
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
3131
handleCloseTab: (targetTabId: string) => void;
3232
setIsSidebarOpen: (isOpen: boolean) => void;
3333
addExistingMessages: (chatId: number | null, existingMessages: Message[]) => void;
@@ -44,7 +44,6 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
4444
const [inputValue, setInputValue] = useAtom(inputValueAtom);
4545
const [inputImage, setInputImage] = useAtom(inputImageAtom);
4646
const [currentChatId, setCurrentChatId] = useAtom(currentChatIdAtom);
47-
const [hasUserSentMessage, setHasUserSentMessage] = useAtom(hasUserSentMessageAtom);
4847
const [isAIResponding, setIsAIResponding] = useAtom(isAIRespondingAtom);
4948
const [isSidebarOpen, setIsSidebarOpen] = useAtom(isSidebarOpenAtom);
5049

@@ -62,27 +61,23 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
6261

6362
// AI 응답이 오면 스피너를 제거
6463
useEffect(() => {
65-
if (messages.length > 0 && hasUserSentMessage) {
64+
console.log('1', isAIResponding);
65+
if (messages.length > 0 && isAIResponding) {
6666
const lastMessage = messages[messages.length - 1];
67-
if (lastMessage && lastMessage.user) {
68-
setIsAIResponding(false);
69-
}
67+
if (lastMessage?.user) setIsAIResponding('style');
7068
}
71-
}, [messages, hasUserSentMessage]);
72-
73-
const resetAtomState = useCallback(
74-
(flag: boolean) => {
75-
setHasUserSentMessage(flag);
76-
setIsAIResponding(flag);
77-
setInputValue('');
78-
setInputImage(undefined);
79-
},
80-
[setHasUserSentMessage, setIsAIResponding, setInputValue, setInputImage],
81-
);
69+
}, [messages, isAIResponding]);
70+
71+
const resetAtomState = useCallback(() => {
72+
setIsAIResponding('');
73+
setInputValue('');
74+
setInputImage(undefined);
75+
}, [setIsAIResponding, setInputValue, setInputImage]);
8276

8377
const sendMsg = useCallback(
8478
(inputValue: string, inputImage?: MessageImage) => {
85-
resetAtomState(true);
79+
resetAtomState();
80+
setIsAIResponding('style');
8681
const userMessage: Message = {
8782
id: Date.now().toString(),
8883
content: inputValue,
@@ -93,8 +88,9 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
9388
createdAt: new Date(),
9489
};
9590
sendMessage(userMessage);
91+
console.log('2', isAIResponding);
9692
},
97-
[currentChatId, sendMessage, setCurrentChatId, setIsAIResponding, setHasUserSentMessage],
93+
[currentChatId, sendMessage, setCurrentChatId, setIsAIResponding],
9894
);
9995

10096
const handleExampleSelect = useCallback(
@@ -115,15 +111,35 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
115111
if (response.status == 'fail') return;
116112
setCurrentChatId(chatId);
117113
addExistingMessages(chatId, response.data.messages);
118-
resetAtomState(false);
114+
resetAtomState();
119115
},
120116
[setCurrentChatId, resetAtomState],
121117
);
122118

119+
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
120+
const files = event.target.files;
121+
if (files && files.length > 0) {
122+
const formData = new FormData();
123+
formData.append('file', files[0]);
124+
const response = await postChatUpload(formData);
125+
if (response.status === 'fail') {
126+
console.error(response.message);
127+
return;
128+
}
129+
const newMessageImage: MessageImage = {
130+
src: response.data,
131+
name: response.data,
132+
content: '이걸 발견했다면 프론트엔드 개발자한테 "ChatInputBox 수정하세요" 라고 말하면 됩니다.',
133+
tags: ['응애'],
134+
};
135+
setInputImage(newMessageImage);
136+
}
137+
};
138+
123139
const handleNewChat = useCallback(() => {
124140
if (chatRoomError) return;
125141
setCurrentChatId(null);
126-
resetAtomState(false);
142+
resetAtomState();
127143
}, [chatRoomError, setCurrentChatId, resetAtomState]);
128144

129145
const handleOpenTab = useCallback(
@@ -183,6 +199,7 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
183199
setImageTabs,
184200
activeImageTabId,
185201
setActiveImageTabId,
202+
handleFileChange,
186203
wikiTabs,
187204
setWikiTabs,
188205
activeWikiTabId,
@@ -195,6 +212,7 @@ export const ChatContextProvider = ({ children }: { children: ReactNode }) => {
195212
handleExampleSelect,
196213
handleSendMessage,
197214
handleChatSelect,
215+
handleFileChange,
198216
handleNewChat,
199217
handleOpenTab,
200218
handleCloseTab,

src/components/chat/ChatInputBox.tsx

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,41 +3,20 @@
33
import { Button } from '@/components/ui/button';
44
import { Plus, Send } from 'lucide-react';
55
import { Textarea } from '@/components/ui/textarea';
6-
import { useRef, ChangeEvent } from 'react';
7-
import { postChatUpload } from '@/api/chatAPI';
6+
import { useRef } from 'react';
87
import Image from 'next/image';
98
import { useAtom, useAtomValue } from 'jotai';
109
import { inputValueAtom, inputImageAtom, isAIRespondingAtom } from '@/atoms/chatAtoms';
1110
import { useChatHandlers } from '@/components/chat/ChatContextProvider';
1211

1312
export default function ChatInputBox() {
1413
const [inputValue, setInputValue] = useAtom(inputValueAtom);
15-
const [inputImage, setInputImage] = useAtom(inputImageAtom);
14+
const inputImage = useAtomValue(inputImageAtom);
1615
const isAIResponding = useAtomValue(isAIRespondingAtom);
17-
18-
const { handleSendMessage } = useChatHandlers();
16+
const { handleSendMessage, handleFileChange } = useChatHandlers();
1917

2018
const fileInputRef = useRef<HTMLInputElement>(null);
2119
const handleButtonClick = () => fileInputRef.current?.click();
22-
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
23-
const files = event.target.files;
24-
if (files && files.length > 0) {
25-
const formData = new FormData();
26-
formData.append('file', files[0]);
27-
const response = await postChatUpload(formData);
28-
if (response.status === 'fail') {
29-
console.error(response.message);
30-
return;
31-
}
32-
const newMessageImage: MessageImage = {
33-
src: response.data,
34-
name: response.data,
35-
content: '이걸 발견했다면 프론트엔드 개발자한테 "ChatInputBox 수정하세요" 라고 말하면 됩니다.',
36-
tags: ['응애'],
37-
};
38-
setInputImage(newMessageImage);
39-
}
40-
};
4120

4221
return (
4322
<div
@@ -73,7 +52,7 @@ export default function ChatInputBox() {
7352
dark:text-white
7453
"
7554
rows={1}
76-
disabled={isAIResponding}
55+
disabled={isAIResponding.length > 0}
7756
/>
7857
<div className="flex space-x-5">
7958
<input
@@ -82,7 +61,7 @@ export default function ChatInputBox() {
8261
onChange={handleFileChange}
8362
className="hidden"
8463
accept="image/*"
85-
disabled={isAIResponding}
64+
disabled={isAIResponding.length > 0}
8665
/>
8766
<Button
8867
onClick={handleButtonClick}
@@ -96,13 +75,13 @@ export default function ChatInputBox() {
9675
transition-all duration-200
9776
mb-1
9877
"
99-
disabled={isAIResponding}
78+
disabled={isAIResponding.length > 0}
10079
>
10180
<Plus />
10281
</Button>
10382
<Button
10483
onClick={handleSendMessage}
105-
disabled={inputValue.trim() === '' || isAIResponding}
84+
disabled={inputValue.trim() === '' || isAIResponding.length > 0}
10685
className="
10786
flex-shrink-0
10887
flex items-center justify-center

0 commit comments

Comments
 (0)