Skip to content

Commit 4e6b88d

Browse files
authored
feat(fe): integrate multi-host API (#197)
* feat: update authentication store and DTOs to include userId * feat: enhance socket service with participant count and host change events * feat: add updateReplyIsHost action and userId to Reply interface * feat: add session user management with API endpoints and state updates * feat: update authentication handling to use setAuthInformation instead of setAccessToken * feat: update ToastMessage component to use bold font style for active toasts * feat: add session settings dropdown
1 parent 49d24ae commit 4e6b88d

26 files changed

+483
-136
lines changed

apps/client/src/components/Header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useToastStore } from '@/features/toast';
77
import { Button, SignInModal, SignUpModal } from '@/components';
88

99
function Header() {
10-
const { isLogin, clearAccessToken } = useAuthStore();
10+
const { isLogin, clearAuthInformation: clearAccessToken } = useAuthStore();
1111

1212
const addToast = useToastStore((state) => state.addToast);
1313

apps/client/src/components/modal/Participant.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { GrValidate } from 'react-icons/gr';
22

3+
import { User } from '@/features/session/session.type';
4+
35
interface ParticipantProps {
4-
name: string;
5-
isHost: boolean;
6+
user: User;
67
onSelect: () => void;
78
}
89

9-
function Participant({ name, isHost, onSelect }: ParticipantProps) {
10+
function Participant({
11+
user: { nickname, isHost },
12+
onSelect,
13+
}: ParticipantProps) {
1014
return (
1115
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
1216
<div
@@ -17,7 +21,7 @@ function Participant({ name, isHost, onSelect }: ParticipantProps) {
1721
<GrValidate
1822
className={`flex-shrink-0 ${isHost ? 'text-indigo-600' : 'text-black-200'}`}
1923
/>
20-
<span className='font-medium'>{name}</span>
24+
<span className='font-medium'>{nickname}</span>
2125
</div>
2226
</div>
2327
);

apps/client/src/components/modal/SessionParticipantsModal.tsx

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,105 @@
1-
import { useState } from 'react';
1+
import { useMutation } from '@tanstack/react-query';
2+
import { isAxiosError } from 'axios';
3+
import { useEffect, useState } from 'react';
24
import { IoClose } from 'react-icons/io5';
35

46
import { useModalContext } from '@/features/modal';
7+
import {
8+
getSessionUsers,
9+
patchSessionHost,
10+
useSessionStore,
11+
} from '@/features/session';
12+
import { useToastStore } from '@/features/toast';
513

614
import { Button } from '@/components';
715
import Participant from '@/components/modal/Participant';
816

917
function SessionParticipantsModal() {
1018
const { closeModal } = useModalContext();
1119

20+
const { addToast } = useToastStore();
21+
22+
const {
23+
sessionUsers,
24+
sessionId,
25+
sessionToken,
26+
setSessionUsers,
27+
updateSessionUser,
28+
} = useSessionStore();
29+
1230
const [selectedUserId, setSelectedUserId] = useState<number>();
1331

14-
const participantList = [
15-
{ id: 1, name: '최정민', isHost: false },
16-
{ id: 2, name: '이상현', isHost: true },
17-
{ id: 3, name: '유영재', isHost: false },
18-
{ id: 4, name: '이지호', isHost: true },
19-
];
32+
const selectedUser = sessionUsers.find(
33+
({ userId }) => userId === selectedUserId,
34+
);
35+
36+
const [searchQuery, setSearchQuery] = useState('');
37+
38+
const { mutate: toggleHost, isPending: isToggleInProgress } = useMutation({
39+
mutationFn: (params: {
40+
userId: number;
41+
sessionId: string;
42+
token: string;
43+
isHost: boolean;
44+
}) =>
45+
patchSessionHost(params.userId, {
46+
token: params.token,
47+
sessionId: params.sessionId,
48+
isHost: params.isHost,
49+
}),
50+
onSuccess: (res) => {
51+
updateSessionUser(res.user);
52+
addToast({
53+
type: 'SUCCESS',
54+
message: `${res.user.nickname}님을 호스트${res.user.isHost ? '로 지정' : '에서 해제'}했습니다.`,
55+
duration: 3000,
56+
});
57+
},
58+
onError: (error) => {
59+
if (!isAxiosError(error)) return;
60+
if (error.response?.status === 403) {
61+
addToast({
62+
type: 'ERROR',
63+
message: '세션 생성자만 권한을 수정할 수 있습니다.',
64+
duration: 3000,
65+
});
66+
}
67+
},
68+
onSettled: () => {
69+
setSelectedUserId(undefined);
70+
},
71+
});
72+
73+
const handleToggleHost = () => {
74+
if (!selectedUser || !sessionId || !sessionToken || isToggleInProgress)
75+
return;
2076

21-
const selectedUser = participantList.find(({ id }) => id === selectedUserId);
77+
toggleHost({
78+
userId: selectedUser.userId,
79+
sessionId,
80+
token: sessionToken,
81+
isHost: !selectedUser.isHost,
82+
});
83+
};
84+
85+
useEffect(() => {
86+
if (sessionId && sessionToken)
87+
getSessionUsers({ sessionId, token: sessionToken }).then(({ users }) => {
88+
setSessionUsers(users);
89+
});
90+
}, [sessionId, sessionToken, setSessionUsers]);
91+
92+
const users = sessionUsers.filter(({ nickname }) =>
93+
nickname.includes(searchQuery),
94+
);
2295

2396
return (
2497
<div className='inline-flex flex-col items-center justify-center gap-2.5 rounded-lg bg-gray-50 p-8 shadow'>
2598
{selectedUser ? (
26-
<div className='flex h-[15dvh] w-full min-w-[25dvw] flex-col justify-center gap-2'>
99+
<div className='flex h-[15dvh] min-w-[17.5dvw] flex-col justify-center gap-2'>
27100
<div className='w-full text-center font-bold'>
28101
<span>
29-
<span className='text-indigo-600'>{selectedUser.name}</span>
102+
<span className='text-indigo-600'>{selectedUser.nickname}</span>
30103
<span>님을</span>
31104
</span>
32105
<br />
@@ -36,7 +109,7 @@ function SessionParticipantsModal() {
36109
: '호스트로 지정하겠습니까?'}
37110
</span>
38111
</div>
39-
<div className='mx-auto mt-4 inline-flex min-w-[22.5dvw] items-start justify-center gap-2.5'>
112+
<div className='mx-auto mt-4 inline-flex w-full items-start justify-center gap-2.5'>
40113
<Button
41114
className='w-full bg-gray-500'
42115
onClick={() => setSelectedUserId(undefined)}
@@ -47,12 +120,10 @@ function SessionParticipantsModal() {
47120
</Button>
48121
<Button
49122
className='w-full bg-indigo-600 transition-colors duration-200'
50-
onClick={() => {
51-
// TODO: 호스트 지정 API 호출
52-
}}
123+
onClick={handleToggleHost}
53124
>
54125
<div className='flex-grow text-sm font-medium text-white'>
55-
지정하기
126+
{selectedUser.isHost ? '해제하기' : '지정하기'}
56127
</div>
57128
</Button>
58129
</div>
@@ -67,12 +138,28 @@ function SessionParticipantsModal() {
67138
onClick={closeModal}
68139
/>
69140
</div>
141+
<div className='relative w-full'>
142+
<input
143+
type='text'
144+
value={searchQuery}
145+
placeholder='유저 이름을 검색하세요'
146+
className='w-full rounded border-gray-500 p-2 pr-8 text-sm font-medium text-gray-500 focus:outline-none'
147+
onChange={(e) => setSearchQuery(e.target.value)}
148+
/>
149+
{searchQuery && (
150+
<IoClose
151+
size={20}
152+
className='absolute right-2 top-2 cursor-pointer text-gray-500 transition-all duration-100 hover:scale-110 hover:text-gray-700'
153+
onClick={() => setSearchQuery('')}
154+
/>
155+
)}
156+
</div>
70157
<ol className='flex w-full flex-col gap-2 overflow-y-auto overflow-x-hidden'>
71-
{participantList.map(({ id, name, isHost }) => (
158+
{users.map((user) => (
72159
<Participant
73-
name={name}
74-
isHost={isHost}
75-
onSelect={() => setSelectedUserId(id)}
160+
key={user.userId}
161+
user={user}
162+
onSelect={() => setSelectedUserId(user.userId)}
76163
/>
77164
))}
78165
</ol>

apps/client/src/components/my/SessionRecord.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Link } from '@tanstack/react-router';
22

3-
import { useSessionStore } from '@/features/session';
43
import { Session } from '@/features/session/session.type';
54

65
import { formatDate } from '@/shared';
@@ -10,10 +9,6 @@ interface SessionRecordProps {
109
}
1110

1211
function SessionRecord({ session }: SessionRecordProps) {
13-
const setSession = useSessionStore((state) => state.setSession);
14-
15-
const handleSessionClick = () => setSession(session);
16-
1712
return (
1813
<div className='flex h-fit flex-col items-start justify-start gap-4 self-stretch border-b border-gray-200 px-2.5 pb-4 pt-2.5'>
1914
<div className='flex h-fit flex-col items-start justify-center gap-2.5 self-stretch'>
@@ -35,7 +30,6 @@ function SessionRecord({ session }: SessionRecordProps) {
3530
<Link
3631
to='/session/$sessionId'
3732
params={{ sessionId: session.sessionId }}
38-
onClick={handleSessionClick}
3933
>
4034
<div className='text-base font-medium leading-normal text-black'>
4135
{session.title}

apps/client/src/components/qna/ChattingList.tsx

Lines changed: 59 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
import { useCallback, useEffect, useRef, useState } from 'react';
22

3-
import { useModal } from '@/features/modal';
43
import { useSessionStore } from '@/features/session';
54
import { useSocket } from '@/features/socket';
65

7-
import { SessionParticipantsModal } from '@/components';
8-
import Button from '@/components/Button';
96
import ChattingMessage from '@/components/qna/ChattingMessage';
107

118
function ChattingList() {
12-
const { chatting } = useSessionStore();
13-
14-
const { Modal, openModal } = useModal(<SessionParticipantsModal />);
9+
const { expired, chatting, participantCount } = useSessionStore();
1510

1611
const [message, setMessage] = useState('');
1712
const [isBottom, setIsBottom] = useState(true);
@@ -66,75 +61,71 @@ function ChattingList() {
6661
}, [chatting, isBottom]);
6762

6863
return (
69-
<>
70-
<div className='inline-flex h-full w-1/5 min-w-[240px] flex-col items-center justify-start rounded-lg bg-white shadow'>
71-
<div className='inline-flex h-[54px] w-full items-center justify-between border-b border-gray-200 px-4 py-3'>
72-
<div className='shrink grow basis-0 text-lg font-medium text-black'>
73-
실시간 채팅
74-
</div>
75-
<Button
76-
className='max-w-[100px] overflow-x-auto whitespace-nowrap bg-green-100 px-2 py-1 transition-colors duration-150 scrollbar-hide hover:bg-green-200'
77-
onClick={openModal}
78-
>
64+
<div className='inline-flex h-full w-1/5 min-w-[240px] flex-col items-center justify-start rounded-lg bg-white shadow'>
65+
<div className='inline-flex h-[54px] w-full items-center justify-between border-b border-gray-200 px-4 py-3'>
66+
<div className='shrink grow basis-0 text-lg font-medium text-black'>
67+
실시간 채팅
68+
</div>
69+
{!expired && (
70+
<div className='max-w-[100px] overflow-x-auto whitespace-nowrap bg-green-100 px-2 py-1 transition-colors duration-150 scrollbar-hide'>
7971
<p className='text-[10px] font-medium text-green-800'>
80-
123123123명 참여중
72+
{participantCount} 참여중
8173
</p>
82-
</Button>
83-
</div>
74+
</div>
75+
)}
76+
</div>
8477

85-
<div
86-
className='inline-flex h-full w-full flex-col items-start justify-start overflow-y-auto overflow-x-hidden break-words p-2.5'
87-
ref={messagesEndRef}
88-
>
89-
{chatting.map((chat) => (
90-
<ChattingMessage key={chat.chattingId} chat={chat} />
91-
))}
92-
</div>
78+
<div
79+
className='inline-flex h-full w-full flex-col items-start justify-start overflow-y-auto overflow-x-hidden break-words p-2.5'
80+
ref={messagesEndRef}
81+
>
82+
{chatting.map((chat) => (
83+
<ChattingMessage key={chat.chattingId} chat={chat} />
84+
))}
85+
</div>
9386

94-
<div className='relative inline-flex h-[75px] w-full items-center justify-center gap-2.5 border-t border-gray-200 bg-gray-50 p-4'>
95-
{!isBottom && userScrolling.current && (
96-
<button
97-
type='button'
98-
onClick={scrollToBottom}
99-
className='absolute bottom-[110%] rounded-full bg-indigo-500 p-2 text-white shadow-lg transition-all hover:bg-indigo-600'
100-
aria-label='맨 아래로 스크롤'
87+
<div className='relative inline-flex h-[75px] w-full items-center justify-center gap-2.5 border-t border-gray-200 bg-gray-50 p-4'>
88+
{!isBottom && userScrolling.current && (
89+
<button
90+
type='button'
91+
onClick={scrollToBottom}
92+
className='absolute bottom-[110%] rounded-full bg-indigo-500 p-2 text-white shadow-lg transition-all hover:bg-indigo-600'
93+
aria-label='맨 아래로 스크롤'
94+
>
95+
<svg
96+
xmlns='http://www.w3.org/2000/svg'
97+
className='h-5 w-5'
98+
viewBox='0 0 20 20'
99+
fill='currentColor'
101100
>
102-
<svg
103-
xmlns='http://www.w3.org/2000/svg'
104-
className='h-5 w-5'
105-
viewBox='0 0 20 20'
106-
fill='currentColor'
107-
>
108-
<path
109-
fillRule='evenodd'
110-
d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'
111-
clipRule='evenodd'
112-
/>
113-
</svg>
114-
</button>
115-
)}
116-
<div className='flex w-full items-center justify-center self-stretch rounded-md border border-gray-200 bg-white p-3'>
117-
<input
118-
value={message}
119-
onChange={(e) => setMessage(e.target.value)}
120-
onKeyDown={(e) => {
121-
if (
122-
e.key === 'Enter' &&
123-
!e.nativeEvent.isComposing &&
124-
message.trim().length
125-
) {
126-
socket?.sendChatMessage(message);
127-
setMessage('');
128-
}
129-
}}
130-
className='w-full text-sm font-medium text-gray-500 focus:outline-none'
131-
placeholder='채팅 메시지를 입력해주세요'
132-
/>
133-
</div>
101+
<path
102+
fillRule='evenodd'
103+
d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'
104+
clipRule='evenodd'
105+
/>
106+
</svg>
107+
</button>
108+
)}
109+
<div className='flex w-full items-center justify-center self-stretch rounded-md border border-gray-200 bg-white p-3'>
110+
<input
111+
value={message}
112+
onChange={(e) => setMessage(e.target.value)}
113+
onKeyDown={(e) => {
114+
if (
115+
e.key === 'Enter' &&
116+
!e.nativeEvent.isComposing &&
117+
message.trim().length
118+
) {
119+
socket?.sendChatMessage(message);
120+
setMessage('');
121+
}
122+
}}
123+
className='w-full text-sm font-medium text-gray-500 focus:outline-none'
124+
placeholder='채팅 메시지를 입력해주세요'
125+
/>
134126
</div>
135127
</div>
136-
{Modal}
137-
</>
128+
</div>
138129
);
139130
}
140131

0 commit comments

Comments
 (0)