ChannelSidebar.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. 'use client';
  2. import './style.scss';
  3. import { useEffect, useState, useCallback } from 'react';
  4. import { useRouter } from 'next/navigation';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faSun, faMoon, faGlobe, faBookmark } from '@fortawesome/free-solid-svg-icons';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import { useSignalRContext } from '@/contexts/signalrProvider';
  9. import { ChannelListItem, ChannelStatusUpdate } from '@/types/channel';
  10. import { ChannelListResponse } from '@/types/response/channel/list';
  11. import useTheme from '@/hooks/useTheme';
  12. export default function ChannelSidebar() {
  13. const [channels, setChannels] = useState<ChannelListItem[]>([]);
  14. const [loading, setLoading] = useState(true);
  15. const [langOpen, setLangOpen] = useState(false);
  16. const router = useRouter();
  17. const { chatConnection } = useSignalRContext();
  18. const { toggleTheme, isDark } = useTheme();
  19. const sortChannels = useCallback((list: ChannelListItem[]) => {
  20. return [...list].sort((a, b) => {
  21. if (a.isLive && !b.isLive) return -1;
  22. if (!a.isLive && b.isLive) return 1;
  23. if (a.isLive && b.isLive) return b.viewerCount - a.viewerCount;
  24. return a.name.localeCompare(b.name);
  25. });
  26. }, []);
  27. const loadChannels = useCallback(async () => {
  28. try {
  29. const res = await fetchApi<ChannelListResponse>('/api/channel/list');
  30. if (res.data?.channels) {
  31. setChannels(sortChannels(res.data.channels));
  32. }
  33. } catch {}
  34. setLoading(false);
  35. }, [sortChannels]);
  36. useEffect(() => {
  37. loadChannels();
  38. }, [loadChannels]);
  39. // SignalR: 채널 상태 실시간 업데이트
  40. useEffect(() => {
  41. if (!chatConnection) return;
  42. const handler = (status: ChannelStatusUpdate) => {
  43. setChannels(prev => {
  44. const updated = prev.map(ch =>
  45. ch.channelSID === status.channelSID
  46. ? { ...ch, isLive: status.isLive, viewerCount: status.viewerCount, videoId: status.videoId }
  47. : ch
  48. );
  49. return sortChannels(updated);
  50. });
  51. };
  52. chatConnection.on('ReceiveChannelStatus', handler);
  53. return () => {
  54. chatConnection.off('ReceiveChannelStatus', handler);
  55. };
  56. }, [chatConnection, sortChannels]);
  57. const handleClick = (ch: ChannelListItem) => {
  58. if (ch.isLive && ch.videoId) {
  59. router.push(`/watch/${ch.channelSID}`);
  60. } else {
  61. router.push(`/channel/${ch.channelSID}`);
  62. }
  63. };
  64. const formatCount = (n: number) => {
  65. if (n >= 10000) return `${(n / 10000).toFixed(1)}만`;
  66. if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
  67. return n.toString();
  68. };
  69. const getThumbnailUrl = (ch: ChannelListItem) => {
  70. if (ch.thumbnailUrl) return ch.thumbnailUrl;
  71. return `https://yt3.googleusercontent.com/a/${ch.channelSID}=s48-c-k-c0x00ffffff-no-rj`;
  72. };
  73. if (loading) {
  74. return (
  75. <div className="channel-sidebar">
  76. <div className="channel-sidebar__header">채널</div>
  77. <div className="channel-sidebar__loading">로딩 중...</div>
  78. </div>
  79. );
  80. }
  81. return (
  82. <div className="channel-sidebar">
  83. <div className="channel-sidebar__header">채널</div>
  84. <div className="channel-sidebar__list">
  85. {channels.length === 0 && (
  86. <div className="channel-sidebar__empty">등록된 채널이 없습니다</div>
  87. )}
  88. {channels.map(ch => (
  89. <div key={ch.channelSID} className={`channel-sidebar__item${ch.isLive ? ' channel-sidebar__item--live' : ''}`} onClick={() => handleClick(ch)}>
  90. <div className="channel-sidebar__thumb">
  91. <img src={getThumbnailUrl(ch)} alt={ch.name} width={36} height={36} />
  92. </div>
  93. <div className="channel-sidebar__info">
  94. <div className="channel-sidebar__name">{ch.name}</div>
  95. <div className="channel-sidebar__sub">
  96. {ch.subscriberCount > 0 ? `구독자 ${formatCount(ch.subscriberCount)}` : ch.handle || ''}
  97. </div>
  98. </div>
  99. <div className="channel-sidebar__status">
  100. {ch.isLive && (
  101. <span className="channel-sidebar__viewers">👁 {formatCount(ch.viewerCount)}</span>
  102. )}
  103. <span className={`channel-sidebar__led${ch.isLive ? ' channel-sidebar__led--on' : ''}`} />
  104. </div>
  105. </div>
  106. ))}
  107. </div>
  108. {/* 하단: 다크모드 / 다국어 / 즐겨찾기 */}
  109. <div className="channel-sidebar__footer">
  110. {langOpen && (
  111. <div className="channel-sidebar__lang-dropdown">
  112. <button type="button" className="channel-sidebar__lang-option channel-sidebar__lang-option--active" onClick={() => setLangOpen(false)}>한국어</button>
  113. <button type="button" className="channel-sidebar__lang-option" onClick={() => setLangOpen(false)}>English</button>
  114. </div>
  115. )}
  116. <div className="channel-sidebar__footer-actions">
  117. <button type="button" className="channel-sidebar__icon-btn" onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
  118. <FontAwesomeIcon icon={isDark ? faSun : faMoon} />
  119. </button>
  120. <button type="button" className="channel-sidebar__icon-btn" onClick={() => setLangOpen(prev => !prev)} aria-label="다국어" title="다국어">
  121. <FontAwesomeIcon icon={faGlobe} />
  122. </button>
  123. <button type="button" className="channel-sidebar__icon-btn" onClick={() => alert('Ctrl+D (Mac: ⌘+D)를 누르면 사이트를 즐겨찾기에 추가할 수 있습니다.')} aria-label="즐겨찾기" title="즐겨찾기">
  124. <FontAwesomeIcon icon={faBookmark} />
  125. </button>
  126. </div>
  127. </div>
  128. </div>
  129. );
  130. }