ChatSidebar.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. 'use client';
  2. import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
  3. import useAuth from '@/hooks/useAuth';
  4. import useChat from '@/hooks/useChat';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faUsers, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock } from '@fortawesome/free-solid-svg-icons';
  7. import './chat-sidebar.scss';
  8. const MIN_FONT_SIZE = 11;
  9. const MAX_FONT_SIZE = 19;
  10. const DEFAULT_FONT_SIZE = 13;
  11. export default function ChatSidebar() {
  12. const { isAuthenticated } = useAuth();
  13. const { messages, systemMessages, participantCount, participants, sendMessage, clearMessages, refreshChat, requestParticipants, chatConnected } = useChat();
  14. const [inputValue, setInputValue] = useState('');
  15. const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE);
  16. const [showMenu, setShowMenu] = useState(false);
  17. const [showTime, setShowTime] = useState(false);
  18. const messagesRef = useRef<HTMLDivElement>(null);
  19. const isAutoScrollRef = useRef(true);
  20. const [showParticipants, setShowParticipants] = useState(false);
  21. const menuRef = useRef<HTMLDivElement>(null);
  22. // 메시지 + 시스템 메시지를 시간순 병합
  23. const mergedMessages = (() => {
  24. const items: Array<
  25. | { type: 'chat'; data: typeof messages[number] }
  26. | { type: 'system'; data: typeof systemMessages[number] }
  27. > = [];
  28. messages.forEach((m) => items.push({ type: 'chat', data: m }));
  29. systemMessages.forEach((m) => items.push({ type: 'system', data: m }));
  30. items.sort((a, b) => {
  31. const timeA = a.type === 'chat' ? a.data.sentAt : a.data.receivedAt;
  32. const timeB = b.type === 'chat' ? b.data.sentAt : b.data.receivedAt;
  33. return new Date(timeA).getTime() - new Date(timeB).getTime();
  34. });
  35. return items;
  36. })();
  37. // 자동 스크롤
  38. useEffect(() => {
  39. const el = messagesRef.current;
  40. if (!el || !isAutoScrollRef.current) return;
  41. el.scrollTop = el.scrollHeight;
  42. }, [mergedMessages.length]);
  43. // 스크롤 이벤트로 자동 스크롤 제어
  44. const handleScroll = useCallback(() => {
  45. const el = messagesRef.current;
  46. if (!el) return;
  47. const threshold = 50;
  48. isAutoScrollRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
  49. }, []);
  50. // 메뉴 외부 클릭 닫기
  51. useEffect(() => {
  52. if (!showMenu) return;
  53. const handleClick = (e: MouseEvent) => {
  54. if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
  55. setShowMenu(false);
  56. }
  57. };
  58. document.addEventListener('mousedown', handleClick);
  59. return () => document.removeEventListener('mousedown', handleClick);
  60. }, [showMenu]);
  61. const handleSend = useCallback(() => {
  62. if (!inputValue.trim()) return;
  63. sendMessage(inputValue);
  64. setInputValue('');
  65. }, [inputValue, sendMessage]);
  66. const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
  67. if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
  68. e.preventDefault();
  69. handleSend();
  70. }
  71. }, [handleSend]);
  72. const handleClear = useCallback(() => {
  73. clearMessages();
  74. setShowMenu(false);
  75. }, [clearMessages]);
  76. const handleFontIncrease = useCallback(() => {
  77. setFontSize((prev) => Math.min(prev + 2, MAX_FONT_SIZE));
  78. setShowMenu(false);
  79. }, []);
  80. const handleFontDecrease = useCallback(() => {
  81. setFontSize((prev) => Math.max(prev - 2, MIN_FONT_SIZE));
  82. setShowMenu(false);
  83. }, []);
  84. const handleRefresh = useCallback(() => {
  85. setShowMenu(false);
  86. refreshChat();
  87. }, [refreshChat]);
  88. const handleToggleTime = useCallback(() => {
  89. setShowTime((prev) => !prev);
  90. setShowMenu(false);
  91. }, []);
  92. const handleShowParticipants = useCallback(() => {
  93. requestParticipants();
  94. setShowParticipants(true);
  95. }, [requestParticipants]);
  96. const handleCloseParticipants = useCallback(() => {
  97. setShowParticipants(false);
  98. }, []);
  99. const formatTime = (dateStr: string) => {
  100. const date = new Date(dateStr);
  101. return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  102. };
  103. return (
  104. <div className='chat-sidebar'>
  105. {/* 헤더 */}
  106. <div className='chat-header'>
  107. <span className='chat-header-title'>실시간 채팅</span>
  108. <div className='chat-header-actions'>
  109. <button type='button' className='chat-participant-count' title='참여자 보기' onClick={handleShowParticipants}>
  110. <FontAwesomeIcon icon={faUsers} />
  111. <span>{participantCount}명</span>
  112. </button>
  113. <div className='chat-menu-wrapper' ref={menuRef}>
  114. <button type='button' title='메뉴' onClick={() => setShowMenu((prev) => !prev)}>
  115. <FontAwesomeIcon icon={faEllipsisVertical} />
  116. </button>
  117. {showMenu && (
  118. <div className='chat-menu'>
  119. <button type='button' onClick={handleClear}>
  120. <FontAwesomeIcon icon={faTrashCan} />
  121. <span>채팅 지우기</span>
  122. </button>
  123. <button type='button' onClick={handleToggleTime}>
  124. <FontAwesomeIcon icon={faClock} />
  125. <span>시간 {showTime ? '숨기기' : '보기'}</span>
  126. </button>
  127. <button type='button' onClick={handleFontIncrease} disabled={fontSize >= MAX_FONT_SIZE}>
  128. <FontAwesomeIcon icon={faMagnifyingGlassPlus} />
  129. <span>글자 크게</span>
  130. </button>
  131. <button type='button' onClick={handleFontDecrease} disabled={fontSize <= MIN_FONT_SIZE}>
  132. <FontAwesomeIcon icon={faMagnifyingGlassMinus} />
  133. <span>글자 작게</span>
  134. </button>
  135. <button type='button' onClick={handleRefresh}>
  136. <FontAwesomeIcon icon={faRotateRight} />
  137. <span>새로고침</span>
  138. </button>
  139. </div>
  140. )}
  141. </div>
  142. </div>
  143. </div>
  144. {/* 메시지 영역 */}
  145. <div className='chat-messages' ref={messagesRef} onScroll={handleScroll} style={{ fontSize: `${fontSize}px` }}>
  146. {!chatConnected && (
  147. <div className='chat-system'>채팅 서버에 연결 중...</div>
  148. )}
  149. {mergedMessages.map((item, index) => {
  150. if (item.type === 'system') {
  151. return (
  152. <div key={`sys-${item.data.id}`} className='chat-system'>
  153. {item.data.content}
  154. </div>
  155. );
  156. }
  157. const msg = item.data;
  158. return (
  159. <div key={`msg-${index}`} className='chat-message'>
  160. {showTime && <span className='chat-message-time'>{formatTime(msg.sentAt)}</span>}
  161. <span className='chat-message-name'>{msg.memberName || msg.memberSID}</span>
  162. <span className='chat-message-content'>{msg.content}</span>
  163. </div>
  164. );
  165. })}
  166. </div>
  167. {/* 입력 영역 */}
  168. <div className='chat-input-area'>
  169. {isAuthenticated ? (
  170. <div className='chat-input-row'>
  171. <input
  172. type='text'
  173. placeholder='메시지를 입력하세요'
  174. value={inputValue}
  175. onChange={(e) => setInputValue(e.target.value)}
  176. onKeyDown={handleKeyDown}
  177. maxLength={500}
  178. disabled={!chatConnected}
  179. />
  180. <button type='button' title='전송' onClick={handleSend} disabled={!chatConnected || !inputValue.trim()}>
  181. <FontAwesomeIcon icon={faPaperPlane} />
  182. </button>
  183. </div>
  184. ) : (
  185. <div className='chat-login-notice'>
  186. <a href='/login'>로그인</a> 후 채팅에 참여하세요
  187. </div>
  188. )}
  189. </div>
  190. {/* 참여자 목록 패널 */}
  191. {showParticipants && (
  192. <>
  193. <div className='chat-participants-overlay' onClick={handleCloseParticipants} />
  194. <div className='chat-participants-dialog'>
  195. <div className='chat-participants-header'>
  196. <h3>참여자 ({participantCount}명)</h3>
  197. <button type='button' onClick={handleCloseParticipants} aria-label='닫기'>&times;</button>
  198. </div>
  199. <ul className='chat-participants-list'>
  200. {participants.length === 0 && participantCount === 0 ? (
  201. <li className='chat-participants-empty'>참여자가 없습니다</li>
  202. ) : (
  203. <>
  204. {participants.map((p) => (
  205. <li key={p.memberName}>
  206. <FontAwesomeIcon icon={faUsers} className='chat-participant-icon' />
  207. <span>{p.memberName}</span>
  208. </li>
  209. ))}
  210. {participantCount - participants.length > 0 && (
  211. <li className='chat-participants-guest'>
  212. <span>비회원 {participantCount - participants.length}명</span>
  213. </li>
  214. )}
  215. </>
  216. )}
  217. </ul>
  218. </div>
  219. </>
  220. )}
  221. </div>
  222. );
  223. }