| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- '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<ChannelListItem[]>(() => sortChannels(initialChannels ?? []));
- const [langOpen, setLangOpen] = useState(false);
- const [collapsed, setCollapsed] = useState(false);
- const [visibleCount, setVisibleCount] = useState<number>(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 (
- <div className={`channel-sidebar${collapsed ? ' channel-sidebar--collapsed' : ''}`}>
- {/* 헤더 */}
- <div className="channel-sidebar__header">
- {!collapsed && <span className="channel-sidebar__header-title">생방송 채널</span>}
- <button
- type="button"
- className="channel-sidebar__collapse-btn"
- onClick={handleToggleCollapse}
- aria-label={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
- aria-expanded={!collapsed}
- title={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
- >
- <FontAwesomeIcon icon={collapsed ? faAnglesRight : faAnglesLeft} />
- </button>
- </div>
- {/* 채널 목록 */}
- <div className="channel-sidebar__list">
- {channels.length === 0 && !collapsed && (
- <div className="channel-sidebar__empty">등록된 채널이 없습니다</div>
- )}
- {visibleChannels.map(ch => (
- <div
- key={ch.channelSID}
- className={`channel-sidebar__item${ch.isLive ? ' channel-sidebar__item--live' : ''}`}
- onClick={() => 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}
- >
- <div className="channel-sidebar__thumb">
- <Image src={getThumbnailUrl(ch)} alt={ch.name} width={36} height={36} unoptimized />
- {collapsed && ch.isLive && <span className="channel-sidebar__thumb-dot" aria-hidden="true" />}
- </div>
- {!collapsed && (
- <>
- <div className="channel-sidebar__info">
- <div className="channel-sidebar__name">{ch.name}</div>
- <div className="channel-sidebar__sub">
- {ch.subscriberCount > 0 ? `구독자 ${formatCount(ch.subscriberCount)}` : (ch.handle ? formatHandle(ch.handle) : '')}
- </div>
- </div>
- <div className="channel-sidebar__status">
- {ch.isLive && (
- <span className="channel-sidebar__viewers">{formatCount(ch.viewerCount)}</span>
- )}
- <span className={`channel-sidebar__led${ch.isLive ? ' channel-sidebar__led--on' : ''}`} />
- </div>
- </>
- )}
- </div>
- ))}
- {/* 더 보기 */}
- {hasMore && (
- <button
- type="button"
- className="channel-sidebar__more"
- onClick={handleShowMore}
- aria-label={`채널 ${PAGE_SIZE}개 더 보기`}
- title={collapsed ? '더 보기' : undefined}
- >
- {collapsed ? <FontAwesomeIcon icon={faEllipsis} /> : <span>더 보기</span>}
- </button>
- )}
- </div>
- {/* 하단: 다크모드 / 다국어 / 즐겨찾기 */}
- <div className="channel-sidebar__footer">
- {langOpen && !collapsed && (
- <div className="channel-sidebar__lang-dropdown">
- <button type="button" className="channel-sidebar__lang-option channel-sidebar__lang-option--active" onClick={() => setLangOpen(false)}>한국어</button>
- <button type="button" className="channel-sidebar__lang-option" onClick={() => setLangOpen(false)}>English</button>
- </div>
- )}
- <div className="channel-sidebar__footer-actions">
- <button type="button" className="channel-sidebar__icon-btn" onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
- <FontAwesomeIcon icon={isDark ? faSun : faMoon} />
- </button>
- <button type="button" className="channel-sidebar__icon-btn" onClick={() => setLangOpen(prev => !prev)} aria-label="다국어" title="다국어">
- <FontAwesomeIcon icon={faGlobe} />
- </button>
- <button type="button" className="channel-sidebar__icon-btn" onClick={() => alert('Ctrl+D (Mac: ⌘+D)를 누르면 사이트를 즐겨찾기에 추가할 수 있습니다.')} aria-label="즐겨찾기" title="즐겨찾기">
- <FontAwesomeIcon icon={faBookmark} />
- </button>
- </div>
- </div>
- </div>
- );
- }
|