LiveExplorer.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. 'use client';
  2. import { useCallback, useState, type ChangeEvent, type FormEvent } from 'react';
  3. import { Search } from 'lucide-react';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import type { LiveChannelItem, LiveChannelListResponse } from '@/types/response/channel/live-list';
  6. import LiveChannelCard from '../_components/LiveChannelCard';
  7. import LiveChannelEmpty from '../_components/LiveChannelEmpty';
  8. type SortKey = 'viewers'|'newest'|'name';
  9. type Props = {
  10. initialChannels: LiveChannelItem[];
  11. initialTotal: number;
  12. pageSize: number;
  13. };
  14. const SORT_OPTIONS: { value: SortKey; label: string }[] = [
  15. { value: 'viewers', label: '시청자순' },
  16. { value: 'newest', label: '최신순' },
  17. { value: 'name', label: '채널명순' }
  18. ];
  19. export default function LiveExplorer({ initialChannels, initialTotal, pageSize }: Props)
  20. {
  21. const [channels, setChannels] = useState<LiveChannelItem[]>(initialChannels);
  22. const [total, setTotal] = useState(initialTotal);
  23. const [keyword, setKeyword] = useState('');
  24. const [appliedKeyword, setAppliedKeyword] = useState('');
  25. const [sort, setSort] = useState<SortKey>('viewers');
  26. const [loading, setLoading] = useState(false);
  27. const [error, setError] = useState<string|null>(null);
  28. const hasMore = channels.length < total;
  29. const fetchList = useCallback(async (kw: string, sortKey: SortKey, offset: number) => {
  30. setLoading(true);
  31. setError(null);
  32. try {
  33. const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset), sort: sortKey });
  34. if (kw) {
  35. params.set('keyword', kw);
  36. }
  37. const res = await fetchApi<LiveChannelListResponse>(`/api/channel/live-list?${params.toString()}`, { silent: true });
  38. if (res.success && res.data) {
  39. const data = res.data;
  40. setChannels(prev => offset === 0 ? data.channels : [...prev, ...data.channels]);
  41. setTotal(data.total);
  42. } else {
  43. setError('목록을 불러오지 못했습니다.');
  44. }
  45. } catch {
  46. setError('목록을 불러오지 못했습니다.');
  47. } finally {
  48. setLoading(false);
  49. }
  50. }, [pageSize]);
  51. const handleSearch = useCallback((e: FormEvent) => {
  52. e.preventDefault();
  53. setAppliedKeyword(keyword);
  54. fetchList(keyword, sort, 0);
  55. }, [keyword, sort, fetchList]);
  56. const handleSortChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
  57. const next = e.target.value as SortKey;
  58. setSort(next);
  59. fetchList(appliedKeyword, next, 0);
  60. }, [appliedKeyword, fetchList]);
  61. const handleLoadMore = useCallback(() => {
  62. if (loading || !hasMore) {
  63. return;
  64. }
  65. fetchList(appliedKeyword, sort, channels.length);
  66. }, [loading, hasMore, appliedKeyword, sort, channels.length, fetchList]);
  67. return (
  68. <section className="live-page" aria-labelledby="live-page-heading">
  69. <header className="live-page__head">
  70. <h1 id="live-page-heading" className="live-page__title">
  71. 생방송
  72. <span className="live-page__count">{total.toLocaleString()}</span>
  73. </h1>
  74. <form className="live-page__controls" onSubmit={handleSearch}>
  75. <select className="live-page__sort" value={sort} onChange={handleSortChange} aria-label="정렬">
  76. {SORT_OPTIONS.map(o => (
  77. <option key={o.value} value={o.value}>{o.label}</option>
  78. ))}
  79. </select>
  80. <input
  81. type="search"
  82. className="live-page__search"
  83. value={keyword}
  84. onChange={e => setKeyword(e.target.value)}
  85. placeholder="채널명 또는 핸들 검색"
  86. aria-label="검색"
  87. />
  88. <button type="submit" className="live-page__search-btn" aria-label="검색"><Search size={18} /></button>
  89. </form>
  90. </header>
  91. {channels.length === 0 ? (
  92. <LiveChannelEmpty />
  93. ) : (
  94. <div className="live-grid" role="list">
  95. {channels.map((ch, idx) => (
  96. <div key={ch.channelSID} role="listitem" className="live-grid__item">
  97. <LiveChannelCard channel={ch} priority={idx < 4} />
  98. </div>
  99. ))}
  100. </div>
  101. )}
  102. {hasMore && (
  103. <div className="home__load-more">
  104. <button type="button" onClick={handleLoadMore} disabled={loading} className="home__load-more-btn">
  105. {loading ? '불러오는 중...' : '더 보기'}
  106. </button>
  107. </div>
  108. )}
  109. {error && <div role="alert" className="home__error">{error}</div>}
  110. </section>
  111. );
  112. }