page.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. 'use client';
  2. import './style.scss';
  3. import { useState, useEffect } from 'react';
  4. import { useSearchParams } from 'next/navigation';
  5. import Image from 'next/image';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import { Checkbox } from '@/components/ui/checkbox';
  8. import { Label } from '@/components/ui/label';
  9. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  10. import {
  11. faLink,
  12. faAlignLeft,
  13. faCalendarDays,
  14. faUsers,
  15. faVideo,
  16. faEye,
  17. faGlobe,
  18. } from '@fortawesome/free-solid-svg-icons';
  19. import type { StudioSettingsResponse } from '@/types/response/studio/settings';
  20. import YouTubeSignBoard from '@/public/resources/YouTube-signboard.svg';
  21. export default function StudioSettingsPage()
  22. {
  23. const searchParams = useSearchParams();
  24. const [data, setData] = useState<StudioSettingsResponse|null>(null);
  25. const [loading, setLoading] = useState(true);
  26. const [connecting, setConnecting] = useState(false);
  27. const [agreedTerms, setAgreedTerms] = useState(false);
  28. const [agreedYouTube, setAgreedYouTube] = useState(false);
  29. const [showDisconnect, setShowDisconnect] = useState(false);
  30. const [agreedDisconnect, setAgreedDisconnect] = useState(false);
  31. const [disconnecting, setDisconnecting] = useState(false);
  32. const connected = searchParams.get('connected') === 'true';
  33. const errorParam = searchParams.get('error');
  34. useEffect(() => {
  35. fetchApi<StudioSettingsResponse>('/api/studio/settings')
  36. .then(res => {
  37. if (res.data) setData(res.data);
  38. })
  39. .catch(() => {
  40. setData({
  41. isYouTubeConnected: false,
  42. channelName: null,
  43. handle: null,
  44. description: null,
  45. channelUrl: null,
  46. thumbnailUrl: null,
  47. bannerUrl: null,
  48. subscriberCount: 0,
  49. videoCount: 0,
  50. viewCount: 0,
  51. youTubePublishedAt: null
  52. });
  53. })
  54. .finally(() => setLoading(false));
  55. }, []);
  56. const handleConnect = async () => {
  57. if (!agreedTerms || !agreedYouTube) {
  58. return;
  59. }
  60. setConnecting(true);
  61. try {
  62. const redirectUri = `${window.location.origin}/api/auth/youtube/callback`;
  63. const res = await fetchApi<string>(`/api/studio/youtube-connect-url?redirectUri=${encodeURIComponent(redirectUri)}`);
  64. window.location.href = res.data!;
  65. } catch (err) {
  66. alert(err instanceof Error ? err.message : 'OAuth URL을 가져오지 못했습니다.');
  67. setConnecting(false);
  68. }
  69. };
  70. const handleDisconnectClick = () => {
  71. if (!window.confirm('YouTube 연동을 해지하시겠습니까?\n해지 시 후원 수신, 라이브 채팅 등의 서비스를 이용할 수 없습니다.')) {
  72. return;
  73. }
  74. setShowDisconnect(true);
  75. };
  76. const handleDisconnect = async () => {
  77. if (!agreedDisconnect) return;
  78. setDisconnecting(true);
  79. try {
  80. await fetchApi('/api/studio/youtube-disconnect', { method: 'POST' });
  81. alert('YouTube 연동이 해지되었습니다.');
  82. window.location.replace('/studio/settings');
  83. } catch (err) {
  84. alert(err instanceof Error ? err.message : '연동 해지에 실패했습니다.');
  85. setDisconnecting(false);
  86. }
  87. };
  88. const handleDisconnectCancel = () => {
  89. setShowDisconnect(false);
  90. setAgreedDisconnect(false);
  91. };
  92. const handleAgreedDisconnectChange = (v: boolean|'indeterminate') => {
  93. setAgreedDisconnect(v === true);
  94. };
  95. const isConnected = data?.isYouTubeConnected;
  96. const canConnect = agreedTerms && agreedYouTube;
  97. return (
  98. <div className="studio-page">
  99. {/* 페이지 헤더 */}
  100. <div className="settings-header">
  101. <h1 className="studio-page__title">
  102. {!loading && isConnected ? '채널 정보' : '채널 설정'}
  103. </h1>
  104. </div>
  105. {loading && <p className="settings-loading">불러오는 중...</p>}
  106. {!loading && !isConnected && (
  107. <>
  108. {errorParam && (
  109. <div className="settings-alert settings-alert--error">
  110. {errorParam === 'cancelled' ? '연결이 취소되었습니다.' : decodeURIComponent(errorParam)}
  111. </div>
  112. )}
  113. <div className="settings-connect-card">
  114. <div className="settings-connect-card__icon">
  115. <YouTubeSignBoard />
  116. </div>
  117. <div className="settings-connect-card__body">
  118. <p className="settings-connect-card__title">YouTube 채널 연결이 필요합니다.</p>
  119. <p className="settings-connect-card__desc">
  120. YouTube 채널을 연결하면 채널 이름, 설명, 시청자 링크 등 채널 정보를 자동으로 가져와 스트리밍과 후원 기능을 원활하게 사용할 수 있습니다.
  121. </p>
  122. <ul className="settings-connect-card__scope-list">
  123. <li>채널 정보 (이름, 설명, 썸네일)</li>
  124. <li>라이브 채팅 읽기/쓰기</li>
  125. <li>채널 멤버십 확인</li>
  126. </ul>
  127. <div className="settings-connect-card__terms">
  128. <div className="settings-connect-card__terms-item">
  129. <Checkbox
  130. id="terms"
  131. checked={agreedTerms}
  132. onCheckedChange={v => setAgreedTerms(v === true)}
  133. />
  134. <Label htmlFor="terms" className="settings-connect-card__terms-label">
  135. <span className="settings-connect-card__terms-required">[필수]</span>
  136. <a
  137. href="/docs/terms"
  138. target="_blank"
  139. rel="noopener noreferrer"
  140. className="settings-connect-card__terms-link"
  141. onClick={e => e.stopPropagation()}
  142. >서비스 이용약관</a>에 동의합니다.
  143. </Label>
  144. </div>
  145. <div className="settings-connect-card__terms-item">
  146. <Checkbox
  147. id="youtube"
  148. checked={agreedYouTube}
  149. onCheckedChange={v => setAgreedYouTube(v === true)}
  150. />
  151. <Label htmlFor="youtube" className="settings-connect-card__terms-label">
  152. <span className="settings-connect-card__terms-required">[필수]</span>
  153. <a
  154. href="https://www.youtube.com/t/terms"
  155. target="_blank"
  156. rel="noopener noreferrer"
  157. className="settings-connect-card__terms-link"
  158. onClick={e => e.stopPropagation()}
  159. >YouTube 서비스 약관</a> 및 데이터 수집에 동의합니다.
  160. </Label>
  161. </div>
  162. </div>
  163. <button
  164. type="button"
  165. className="settings-connect-card__btn"
  166. disabled={!canConnect || connecting}
  167. onClick={handleConnect}
  168. >
  169. <YouTubeIcon />
  170. {connecting ? '연결 중...' : 'YouTube로 연결하기'}
  171. </button>
  172. </div>
  173. </div>
  174. </>
  175. )}
  176. {!loading && isConnected && (
  177. <div className="settings-channel">
  178. {connected && (
  179. <div className="settings-alert settings-alert--success">
  180. YouTube 채널이 성공적으로 연결되었습니다.
  181. </div>
  182. )}
  183. {/* 배너 */}
  184. {data?.bannerUrl && (
  185. <div className="settings-channel__banner">
  186. <Image
  187. src={`${data.bannerUrl}=w1138`}
  188. alt="채널 배너"
  189. width={1138}
  190. height={190}
  191. className="settings-channel__banner-img"
  192. priority
  193. />
  194. </div>
  195. )}
  196. {/* 프로필 영역 */}
  197. <div className="settings-channel__profile">
  198. {data?.thumbnailUrl ? (
  199. <Image
  200. src={data.thumbnailUrl}
  201. alt={data?.channelName ?? '채널'}
  202. width={80}
  203. height={80}
  204. className="settings-channel__avatar"
  205. />
  206. ) : (
  207. <div className="settings-channel__avatar-placeholder" />
  208. )}
  209. <div className="settings-channel__identity">
  210. <h2 className="settings-channel__name">{data?.channelName}</h2>
  211. <div className="settings-channel__meta">
  212. {data?.handle && <span>{data.handle}</span>}
  213. <span>구독자 {data?.subscriberCount.toLocaleString()}명</span>
  214. <span>동영상 {data?.videoCount.toLocaleString()}개</span>
  215. </div>
  216. </div>
  217. </div>
  218. {/* 상세 정보 */}
  219. <div className="settings-channel__details">
  220. {data?.description && (
  221. <div className="settings-channel__row">
  222. <FontAwesomeIcon icon={faAlignLeft} className="settings-channel__icon" />
  223. <div className="settings-channel__value">
  224. <span className="settings-channel__label">설명</span>
  225. <p className="settings-channel__desc">{data.description}</p>
  226. </div>
  227. </div>
  228. )}
  229. {data?.channelUrl && (
  230. <div className="settings-channel__row">
  231. <FontAwesomeIcon icon={faLink} className="settings-channel__icon" />
  232. <div className="settings-channel__value">
  233. <span className="settings-channel__label">채널 주소</span>
  234. <a
  235. href={data.channelUrl}
  236. target="_blank"
  237. rel="noopener noreferrer"
  238. className="settings-channel__link"
  239. >{data.channelUrl}</a>
  240. </div>
  241. </div>
  242. )}
  243. <div className="settings-channel__row">
  244. <FontAwesomeIcon icon={faGlobe} className="settings-channel__icon" />
  245. <div className="settings-channel__value">
  246. <span className="settings-channel__label">조회수</span>
  247. <span>조회수 {data?.viewCount.toLocaleString()}회</span>
  248. </div>
  249. </div>
  250. {data?.youTubePublishedAt && (
  251. <div className="settings-channel__row">
  252. <FontAwesomeIcon icon={faCalendarDays} className="settings-channel__icon" />
  253. <div className="settings-channel__value">
  254. <span className="settings-channel__label">가입일</span>
  255. <span>{new Date(data.youTubePublishedAt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
  256. </div>
  257. </div>
  258. )}
  259. <div className="settings-channel__stats">
  260. <div className="settings-channel__stat">
  261. <FontAwesomeIcon icon={faUsers} className="settings-channel__stat-icon" />
  262. <span className="settings-channel__stat-value">{data?.subscriberCount.toLocaleString()}</span>
  263. <span className="settings-channel__stat-label">구독자</span>
  264. </div>
  265. <div className="settings-channel__stat">
  266. <FontAwesomeIcon icon={faVideo} className="settings-channel__stat-icon" />
  267. <span className="settings-channel__stat-value">{data?.videoCount.toLocaleString()}</span>
  268. <span className="settings-channel__stat-label">동영상</span>
  269. </div>
  270. <div className="settings-channel__stat">
  271. <FontAwesomeIcon icon={faEye} className="settings-channel__stat-icon" />
  272. <span className="settings-channel__stat-value">{data?.viewCount.toLocaleString()}</span>
  273. <span className="settings-channel__stat-label">조회수</span>
  274. </div>
  275. </div>
  276. </div>
  277. {/* 연동 해지 */}
  278. {!showDisconnect ? (
  279. <div className="settings-disconnect">
  280. <div className='settings-disconnect__logo'>
  281. <YouTubeSignBoard />
  282. </div>
  283. <p className="settings-disconnect__hint">
  284. YouTube 연동을 해지하면 후원 수신, 라이브 채팅 등의 서비스를 이용할 수 없습니다. <br/>
  285. <button
  286. type="button"
  287. className="settings-disconnect__trigger"
  288. onClick={handleDisconnectClick}
  289. >
  290. 연동 해지하기
  291. </button>
  292. </p>
  293. </div>
  294. ) : (
  295. <div className="settings-disconnect__card">
  296. <p className="settings-disconnect__card-title">YouTube 연동 해지</p>
  297. <p className="settings-disconnect__card-desc">
  298. 연동을 해지하면 아래 서비스를 더 이상 이용할 수 없습니다.
  299. </p>
  300. <ul className="settings-disconnect__impact-list">
  301. <li>후원 수신 (시청자로부터 후원을 받을 수 없습니다)</li>
  302. <li>라이브 채팅 읽기/쓰기</li>
  303. <li>채널 멤버십 확인</li>
  304. <li>채널 정보 자동 동기화</li>
  305. </ul>
  306. <div className="settings-disconnect__terms">
  307. <div className="settings-disconnect__terms-item">
  308. <Checkbox
  309. id="disconnect"
  310. checked={agreedDisconnect}
  311. onCheckedChange={handleAgreedDisconnectChange}
  312. />
  313. <Label htmlFor="disconnect" className="settings-disconnect__terms-label">
  314. <span className="settings-disconnect__terms-required">[필수]</span>
  315. 위 사항을 이해하고 YouTube 연동을 해지합니다.
  316. </Label>
  317. </div>
  318. </div>
  319. <div className="settings-disconnect__actions">
  320. <button
  321. type="button"
  322. className="settings-disconnect__btn settings-disconnect__btn--cancel"
  323. onClick={handleDisconnectCancel}
  324. >
  325. 취소
  326. </button>
  327. <button
  328. type="button"
  329. className="settings-disconnect__btn settings-disconnect__btn--danger"
  330. disabled={!agreedDisconnect || disconnecting}
  331. onClick={handleDisconnect}
  332. >
  333. {disconnecting ? '해지 중...' : '연동 해지'}
  334. </button>
  335. </div>
  336. </div>
  337. )}
  338. </div>
  339. )}
  340. </div>
  341. );
  342. }
  343. function YouTubeIcon() {
  344. return (
  345. <svg viewBox="0 0 24 24" fill="currentColor" width={18} height={18}>
  346. <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
  347. </svg>
  348. );
  349. }