ChatDock.tsx 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. 'use client';
  2. import '@/app/(main)/chat/style.scss';
  3. import '@/app/styles/chat-dock.scss';
  4. import { useCallback, useEffect, useState } from 'react';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faComments, faAnglesRight, faXmark } from '@fortawesome/free-solid-svg-icons';
  7. import MainChat from '@/app/(main)/chat/MainChat';
  8. const STORAGE_KEY = 'az-chat-collapsed';
  9. const WIDTH_EXPANDED = '340px';
  10. const WIDTH_COLLAPSED = '52px';
  11. // 우측 고정 채팅 도크 — (main)/Layout 에 단일 인스턴스로 상시 마운트되어 페이지 전환에도 유지된다(App Router layout 은 자식 라우트 이동에 언마운트 안 됨).
  12. // MainChat 은 접힘(desktop 레일)/모바일 닫힘 상태에서도 언마운트하지 않고 CSS 로만 숨겨 SignalR 참가(JoinRoom 'main')·메시지·스크롤을 보존한다.
  13. // → 이 도크가 'main' 룸의 유일한 참가자여야 이중 JoinRoom/LeaveRoom 충돌이 없다 (그래서 /chat 페이지는 별도 MainChat 을 마운트하지 않는다).
  14. export default function ChatDock()
  15. {
  16. // 데스크톱 접힘(레일) — localStorage 영속. 기본 펼침.
  17. const [collapsed, setCollapsed] = useState(false);
  18. // 모바일 드로어 열림 — 비영속, 기본 닫힘.
  19. const [drawerOpen, setDrawerOpen] = useState(false);
  20. // 최초 mount 시 localStorage 에서 collapsed 복원
  21. useEffect(() => {
  22. try {
  23. const saved = window.localStorage.getItem(STORAGE_KEY);
  24. if (saved === 'true') {
  25. setCollapsed(true);
  26. }
  27. } catch {}
  28. }, []);
  29. // collapsed 변경 시 html 루트 CSS 변수 동기화 — desktop grid 의 chat 컬럼 폭(모바일은 fixed 라 무영향)
  30. useEffect(() => {
  31. const root = document.documentElement;
  32. root.style.setProperty('--antooza-chat-width', collapsed ? WIDTH_COLLAPSED : WIDTH_EXPANDED);
  33. try {
  34. window.localStorage.setItem(STORAGE_KEY, String(collapsed));
  35. } catch {}
  36. return () => {
  37. root.style.removeProperty('--antooza-chat-width');
  38. };
  39. }, [collapsed]);
  40. const collapse = useCallback(() => setCollapsed(true), []);
  41. const expand = useCallback(() => setCollapsed(false), []);
  42. const openDrawer = useCallback(() => setDrawerOpen(true), []);
  43. const closeDrawer = useCallback(() => setDrawerOpen(false), []);
  44. // MainChat 헤더(.chat-room__meta)에 주입할 컨트롤 — desktop=접기, mobile=드로어 닫기 (CSS 로 상호 배타 노출)
  45. const headerControls = (
  46. <>
  47. <button type='button' className='chat-dock__ctrl chat-dock__ctrl--collapse' onClick={collapse} aria-label='채팅 접기' title='채팅 접기'>
  48. <FontAwesomeIcon icon={faAnglesRight} />
  49. </button>
  50. <button type='button' className='chat-dock__ctrl chat-dock__ctrl--close' onClick={closeDrawer} aria-label='채팅 닫기' title='채팅 닫기'>
  51. <FontAwesomeIcon icon={faXmark} />
  52. </button>
  53. </>
  54. );
  55. return (
  56. <>
  57. <aside
  58. className={`chat-dock${collapsed ? ' chat-dock--collapsed' : ''}${drawerOpen ? ' chat-dock--open' : ''}`}
  59. aria-label='실시간 채팅'
  60. >
  61. {/* 접힘 레일 (desktop 전용) — 펼치기 */}
  62. <button type='button' className='chat-dock__rail' onClick={expand} aria-label='채팅 펼치기' title='채팅 펼치기'>
  63. <FontAwesomeIcon icon={faComments} />
  64. <span className='chat-dock__rail-label'>채팅</span>
  65. </button>
  66. {/* 채팅 본체 — 상시 마운트, 접힘/닫힘 시 CSS 로만 숨김 */}
  67. <div className='chat-dock__inner'>
  68. <MainChat headerSlot={headerControls} />
  69. </div>
  70. </aside>
  71. {/* 모바일 플로팅 토글 */}
  72. <button
  73. type='button'
  74. className='chat-dock__fab'
  75. onClick={openDrawer}
  76. aria-label='실시간 채팅 열기'
  77. aria-expanded={drawerOpen}
  78. >
  79. <FontAwesomeIcon icon={faComments} />
  80. </button>
  81. {/* 모바일 오버레이 */}
  82. <div
  83. className={`chat-dock__overlay${drawerOpen ? ' chat-dock__overlay--visible' : ''}`}
  84. onClick={closeDrawer}
  85. aria-hidden='true'
  86. />
  87. </>
  88. );
  89. }