CreatorExplorer.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. 'use client';
  2. import { useEffect, useMemo, useState, type ChangeEvent } from 'react';
  3. import { Search } from 'lucide-react';
  4. import type { CreatorItem } from '@/types/response/channel/creators';
  5. import CreatorCard from './CreatorCard';
  6. type SortKey = 'random'|'subscribers'|'followers'|'name';
  7. const SORT_OPTIONS: { value: SortKey; label: string }[] = [
  8. { value: 'random', label: '무작위' },
  9. { value: 'subscribers', label: '구독자순' },
  10. { value: 'followers', label: '팔로워순' },
  11. { value: 'name', label: '이름순' }
  12. ];
  13. const PAGE_SIZE = 50;
  14. function shuffle<T>(arr: T[]): T[]
  15. {
  16. const a = [...arr];
  17. for (let i = a.length - 1; i > 0; i--) {
  18. const j = Math.floor(Math.random() * (i + 1));
  19. [a[i], a[j]] = [a[j], a[i]];
  20. }
  21. return a;
  22. }
  23. export default function CreatorExplorer({ creators }: { creators: CreatorItem[] })
  24. {
  25. const [keyword, setKeyword] = useState('');
  26. const [sort, setSort] = useState<SortKey>('random');
  27. const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
  28. const [randomized, setRandomized] = useState<CreatorItem[]|null>(null);
  29. // 첫 진입 무작위 정렬 — 마운트 후 1회 (SSR/hydration 불일치 방지)
  30. useEffect(() => {
  31. setRandomized(shuffle(creators));
  32. }, [creators]);
  33. const filtered = useMemo(() => {
  34. let list = sort === 'random' ? (randomized ?? creators) : [...creators];
  35. const kw = keyword.trim().toLowerCase();
  36. if (kw) {
  37. list = list.filter(c => c.name.toLowerCase().includes(kw) || (c.handle?.toLowerCase().includes(kw) ?? false));
  38. }
  39. if (sort === 'subscribers') {
  40. list = [...list].sort((a, b) => b.subscriberCount - a.subscriberCount);
  41. } else if (sort === 'followers') {
  42. list = [...list].sort((a, b) => b.followerCount - a.followerCount);
  43. } else if (sort === 'name') {
  44. list = [...list].sort((a, b) => a.name.localeCompare(b.name));
  45. }
  46. return list;
  47. }, [creators, randomized, keyword, sort]);
  48. const visible = filtered.slice(0, visibleCount);
  49. const hasMore = visible.length < filtered.length;
  50. const handleSortChange = (e: ChangeEvent<HTMLSelectElement>) => {
  51. setSort(e.target.value as SortKey);
  52. setVisibleCount(PAGE_SIZE);
  53. };
  54. const handleKeywordChange = (e: ChangeEvent<HTMLInputElement>) => {
  55. setKeyword(e.target.value);
  56. setVisibleCount(PAGE_SIZE);
  57. };
  58. return (
  59. <section>
  60. <header className="mb-4 flex flex-wrap items-center justify-between gap-3">
  61. <h1 className="flex items-center gap-2 text-xl font-bold">
  62. 크리에이터
  63. <span className="rounded-full bg-[var(--bg-elevated)] px-2 py-0.5 text-sm font-semibold text-[var(--text-secondary)]">
  64. {filtered.length.toLocaleString()}
  65. </span>
  66. </h1>
  67. <div className="flex flex-wrap gap-2">
  68. <select
  69. value={sort}
  70. onChange={handleSortChange}
  71. aria-label="정렬"
  72. className="h-9 rounded-lg border border-[var(--border-default)] bg-[var(--bg-elevated)] px-3 text-sm"
  73. >
  74. {SORT_OPTIONS.map(o => (
  75. <option key={o.value} value={o.value}>{o.label}</option>
  76. ))}
  77. </select>
  78. <form role="search" onSubmit={(e) => e.preventDefault()} className="flex gap-2">
  79. <input
  80. type="search"
  81. value={keyword}
  82. onChange={handleKeywordChange}
  83. placeholder="채널명 또는 핸들 검색"
  84. aria-label="검색"
  85. className="h-9 min-w-[200px] rounded-lg border border-[var(--border-default)] bg-[var(--bg-elevated)] px-3 text-sm"
  86. />
  87. <button
  88. type="submit"
  89. aria-label="검색"
  90. className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#FFD700] text-[#333] transition hover:bg-[#FFC400]"
  91. >
  92. <Search className="size-4" />
  93. </button>
  94. </form>
  95. </div>
  96. </header>
  97. {filtered.length === 0 ? (
  98. <p className="py-16 text-center text-[var(--text-muted)]">크리에이터가 없습니다.</p>
  99. ) : (
  100. <>
  101. <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
  102. {visible.map(c => (
  103. <CreatorCard key={c.channelSID} creator={c} />
  104. ))}
  105. </div>
  106. {hasMore && (
  107. <div className="mt-6 flex justify-center">
  108. <button
  109. type="button"
  110. onClick={() => setVisibleCount(v => v + PAGE_SIZE)}
  111. className="rounded-lg border border-[var(--border-default)] px-5 py-2 text-sm font-semibold"
  112. >
  113. 더 보기
  114. </button>
  115. </div>
  116. )}
  117. </>
  118. )}
  119. </section>
  120. );
  121. }