'use client'; import './style.scss'; import { useEffect, useState, useCallback } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSun, faMoon, faGlobe, faBookmark, faAnglesLeft, faAnglesRight, faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { useSignalRContext } from '@/contexts/signalrProvider'; import { ChannelListItem, ChannelStatusUpdate } from '@/types/channel'; import { buildChannelUrl, buildWatchUrl, formatHandle } from '@/lib/utils/channel'; import useTheme from '@/hooks/useTheme'; const STORAGE_KEY = 'antooza-sidebar-collapsed'; const PAGE_SIZE = 10; const WIDTH_EXPANDED = '220px'; const WIDTH_COLLAPSED = '64px'; const THUMB_PLACEHOLDER = '/resources/no-image.png'; // 뷰포트 높이 기반 최초 표시 채널 수 계산용 상수 // 채널 아이템 1개 ≒ padding 10px*2 + thumb 36px = 56px // 헤더(약 60) + 푸터(약 70) + "더 보기" 버튼(약 46) ≒ 180px 예약 const ITEM_HEIGHT_PX = 56; const RESERVED_HEIGHT_PX = 180; function computeInitialVisible(): number { if (typeof window === 'undefined') { return PAGE_SIZE; } const available = window.innerHeight - RESERVED_HEIGHT_PX; const fit = Math.floor(available / ITEM_HEIGHT_PX); return Math.max(PAGE_SIZE, fit); } type Props = { initialChannels?: ChannelListItem[]; }; export default function ChannelSidebar({ initialChannels }: Props) { const sortChannels = useCallback((list: ChannelListItem[]) => { return [...list].sort((a, b) => { if (a.isLive && !b.isLive) { return -1; } if (!a.isLive && b.isLive) { return 1; } if (a.isLive && b.isLive) { return b.viewerCount - a.viewerCount; } // 비라이브: 서버가 반환한 random 순서 유지 (_offlineRank 는 로드 시점 index 부여) return (a._offlineRank ?? 0) - (b._offlineRank ?? 0); }); }, []); const [channels, setChannels] = useState(() => sortChannels(initialChannels ?? [])); const [langOpen, setLangOpen] = useState(false); const [collapsed, setCollapsed] = useState(false); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const router = useRouter(); // ChannelStatusBroadcaster 는 AppHub 의 channel:{sid} 그룹으로 송출하므로 appConnection 사용 필수. // (ChatHub 를 쓰면 그룹 키(chat:channel:{sid}) 불일치로 ReceiveChannelStatus 가 영영 안 옴 + 채팅 입장 메시지 도배) const { appConnection } = useSignalRContext(); const { toggleTheme, isDark } = useTheme(); // 최초 mount 시 localStorage에서 collapsed 상태 복원 useEffect(() => { try { const saved = window.localStorage.getItem(STORAGE_KEY); if (saved === 'true') { setCollapsed(true); } } catch {} }, []); // 뷰포트 높이에 따라 최초 표시 채널 수 계산 — 4K/8K 등 세로가 긴 화면에서 더 많은 채널을 한번에 표시 // 사용자가 "더 보기" 를 눌러 늘려놓은 경우 줄어들지 않도록 max 로 갱신 useEffect(() => { const apply = () => { const next = computeInitialVisible(); setVisibleCount(prev => Math.max(prev, next)); }; apply(); window.addEventListener('resize', apply); return () => { window.removeEventListener('resize', apply); }; }, []); // collapsed 변경 시 html 루트의 CSS variable + 클래스 동기화 (layout grid가 반응) useEffect(() => { const root = document.documentElement; root.style.setProperty('--antooza-sidebar-width', collapsed ? WIDTH_COLLAPSED : WIDTH_EXPANDED); root.classList.toggle('antooza-sidebar-collapsed', collapsed); try { window.localStorage.setItem(STORAGE_KEY, String(collapsed)); } catch {} return () => { // unmount 시 원복 (전체 앱 재사용성 보장) root.style.removeProperty('--antooza-sidebar-width'); root.classList.remove('antooza-sidebar-collapsed'); }; }, [collapsed]); // SignalR: 채널 상태 실시간 업데이트 (AppHub.ReceiveChannelStatus) useEffect(() => { if (!appConnection) { return; } const handler = (status: ChannelStatusUpdate) => { setChannels(prev => { const updated = prev.map(ch => ch.channelSID === status.channelSID ? { ...ch, isLive: status.isLive, viewerCount: status.viewerCount, videoId: status.videoId } : ch ); return sortChannels(updated); }); }; appConnection.on('ReceiveChannelStatus', handler); return () => { appConnection.off('ReceiveChannelStatus', handler); }; }, [appConnection, sortChannels]); // ChannelStatusBroadcaster 가 AppHub 의 Clients.Group("channel:{sid}") 로 송출 → AppHub.JoinChannel 로 그룹 가입. // AppHub.JoinChannel 은 순수 그룹 가입만 수행 (사이드이펙트 없음). // SID 목록 변경 또는 재연결 시 다시 가입. const channelSidsKey = channels.map(c => c.channelSID).join(','); useEffect(() => { if (!appConnection) { return; } const sids = channelSidsKey ? channelSidsKey.split(',') : []; if (sids.length === 0) { return; } const joinAll = async () => { if (appConnection.state !== 'Connected') { return; } for (const sid of sids) { try { await appConnection.invoke('JoinChannel', sid); } catch (err) { console.warn('[ChannelSidebar] JoinChannel 실패:', sid, err); } } }; joinAll(); appConnection.onreconnected(joinAll); return () => { if (appConnection.state !== 'Connected') { return; } for (const sid of sids) { appConnection.invoke('LeaveChannel', sid).catch(() => {}); } }; }, [appConnection, channelSidsKey]); const handleClick = (ch: ChannelListItem) => { if (ch.isLive && ch.videoId) { router.push(buildWatchUrl(ch)); } else { router.push(buildChannelUrl(ch)); } }; const formatCount = (n: number) => { if (n >= 10000) { return `${(n / 10000).toFixed(1)}만`; } if (n >= 1000) { return `${(n / 1000).toFixed(1)}K`; } return n.toString(); }; const getThumbnailUrl = (ch: ChannelListItem) => { if (ch.thumbnailUrl) { return ch.thumbnailUrl; } // YouTube 채널 ID 를 `/a/` 경로에 끼워넣은 fallback 은 항상 400 (해당 path 는 Google 계정 아바타 토큰용) // → 로컬 placeholder 로 안전 대체. 실제 채널 썸네일은 YouTube Data API 동기화로 채워야 함. return THUMB_PLACEHOLDER; }; const handleToggleCollapse = useCallback(() => { setCollapsed(prev => !prev); setLangOpen(false); }, []); const handleShowMore = useCallback(() => { setVisibleCount(prev => prev + PAGE_SIZE); }, []); const visibleChannels = channels.slice(0, visibleCount); const hasMore = channels.length > visibleCount; return (
{/* 헤더 */}
{!collapsed && 생방송 채널}
{/* 채널 목록 */}
{channels.length === 0 && !collapsed && (
등록된 채널이 없습니다
)} {visibleChannels.map(ch => (
handleClick(ch)} role="button" tabIndex={0} aria-label={`${ch.name}${ch.isLive ? ` · 시청자 ${formatCount(ch.viewerCount)}` : ''}`} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(ch); } }} title={collapsed ? `${ch.name}${ch.isLive ? ` · 시청자 ${formatCount(ch.viewerCount)}` : ''}` : undefined} >
{ch.name} {collapsed && ch.isLive &&
{!collapsed && ( <>
{ch.name}
{ch.subscriberCount > 0 ? `구독자 ${formatCount(ch.subscriberCount)}` : (ch.handle ? formatHandle(ch.handle) : '')}
{ch.isLive && ( {formatCount(ch.viewerCount)} )}
)}
))} {/* 더 보기 */} {hasMore && ( )}
{/* 하단: 다크모드 / 다국어 / 즐겨찾기 */}
{langOpen && !collapsed && (
)}
); }