page.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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. donationCode: null
  53. });
  54. })
  55. .finally(() => setLoading(false));
  56. }, []);
  57. const handleConnect = async () => {
  58. if (!agreedTerms || !agreedYouTube) {
  59. return;
  60. }
  61. setConnecting(true);
  62. try {
  63. // 서버 사이드 콜백 라우트와 정확히 동일한 redirect_uri 를 만들어야 Google 이 토큰 교환 시 매칭 성공.
  64. // 환경변수로 고정해서 클라이언트(브라우저)·서버(IIS 뒤 Next.js) 양쪽에서 같은 값이 나오도록 통일.
  65. const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || window.location.origin;
  66. const redirectUri = `${siteUrl}/api/auth/youtube/callback`;
  67. const res = await fetchApi<string>(`/api/studio/youtube-connect-url?redirectUri=${encodeURIComponent(redirectUri)}`);
  68. window.location.href = res.data!;
  69. } catch (err) {
  70. alert(err instanceof Error ? err.message : 'OAuth URL을 가져오지 못했습니다.');
  71. setConnecting(false);
  72. }
  73. };
  74. const handleDisconnectClick = () => {
  75. if (!window.confirm('YouTube 연동을 해지하시겠습니까?\n해지 시 후원 수신, 라이브 채팅 등의 서비스를 이용할 수 없습니다.')) {
  76. return;
  77. }
  78. setShowDisconnect(true);
  79. };
  80. const handleDisconnect = async () => {
  81. if (!agreedDisconnect) return;
  82. setDisconnecting(true);
  83. try {
  84. await fetchApi('/api/studio/youtube-disconnect', { method: 'POST' });
  85. alert('YouTube 연동이 해지되었습니다.');
  86. window.location.replace('/studio/settings');
  87. } catch (err) {
  88. alert(err instanceof Error ? err.message : '연동 해지에 실패했습니다.');
  89. setDisconnecting(false);
  90. }
  91. };
  92. const handleDisconnectCancel = () => {
  93. setShowDisconnect(false);
  94. setAgreedDisconnect(false);
  95. };
  96. const handleAgreedDisconnectChange = (v: boolean|'indeterminate') => {
  97. setAgreedDisconnect(v === true);
  98. };
  99. const isConnected = data?.isYouTubeConnected;
  100. const canConnect = agreedTerms && agreedYouTube;
  101. return (
  102. <div className="studio-page">
  103. {/* 페이지 헤더 */}
  104. <div className="settings-header">
  105. <h1 className="studio-page__title">
  106. {!loading && isConnected ? '채널 정보' : '채널 설정'}
  107. </h1>
  108. </div>
  109. {loading && <p className="settings-loading">불러오는 중...</p>}
  110. {!loading && !isConnected && (
  111. <>
  112. {errorParam && (
  113. <div className="settings-alert settings-alert--error">
  114. {errorParam === 'cancelled' ? '연결이 취소되었습니다.' : decodeURIComponent(errorParam)}
  115. </div>
  116. )}
  117. <div className="settings-connect-card">
  118. <div className="settings-connect-card__icon">
  119. <YouTubeSignBoard />
  120. </div>
  121. <div className="settings-connect-card__body">
  122. <p className="settings-connect-card__title">YouTube 채널 연결이 필요합니다.</p>
  123. <p className="settings-connect-card__desc">
  124. YouTube 채널을 연결하면 채널 이름, 설명, 시청자 링크 등 채널 정보를 자동으로 가져와 스트리밍과 후원 기능을 원활하게 사용할 수 있습니다.
  125. </p>
  126. <ul className="settings-connect-card__scope-list">
  127. <li>채널 정보 (이름, 설명, 썸네일)</li>
  128. <li>라이브 채팅 읽기/쓰기</li>
  129. <li>채널 멤버십 확인</li>
  130. </ul>
  131. <div className="settings-connect-card__terms">
  132. <div className="settings-connect-card__terms-item">
  133. <Checkbox
  134. id="terms"
  135. checked={agreedTerms}
  136. onCheckedChange={v => setAgreedTerms(v === true)}
  137. />
  138. <Label htmlFor="terms" className="settings-connect-card__terms-label">
  139. <span className="settings-connect-card__terms-required">[필수]</span>
  140. <a
  141. href="/docs/terms"
  142. target="_blank"
  143. rel="noopener noreferrer"
  144. className="settings-connect-card__terms-link"
  145. onClick={e => e.stopPropagation()}
  146. >서비스 이용약관</a>에 동의합니다.
  147. </Label>
  148. </div>
  149. <div className="settings-connect-card__terms-item">
  150. <Checkbox
  151. id="youtube"
  152. checked={agreedYouTube}
  153. onCheckedChange={v => setAgreedYouTube(v === true)}
  154. />
  155. <Label htmlFor="youtube" className="settings-connect-card__terms-label">
  156. <span className="settings-connect-card__terms-required">[필수]</span>
  157. <a
  158. href="https://www.youtube.com/t/terms"
  159. target="_blank"
  160. rel="noopener noreferrer"
  161. className="settings-connect-card__terms-link"
  162. onClick={e => e.stopPropagation()}
  163. >YouTube 서비스 약관</a> 및 데이터 수집에 동의합니다.
  164. </Label>
  165. </div>
  166. </div>
  167. <button
  168. type="button"
  169. className="settings-connect-card__btn"
  170. disabled={!canConnect || connecting}
  171. onClick={handleConnect}
  172. >
  173. <YouTubeIcon />
  174. {connecting ? '연결 중...' : 'YouTube로 연결하기'}
  175. </button>
  176. </div>
  177. </div>
  178. </>
  179. )}
  180. {!loading && isConnected && (
  181. <div className="settings-channel">
  182. {connected && (
  183. <div className="settings-alert settings-alert--success">
  184. YouTube 채널이 성공적으로 연결되었습니다.
  185. </div>
  186. )}
  187. {/* 배너 */}
  188. {data?.bannerUrl && (
  189. <div className="settings-channel__banner">
  190. <Image
  191. src={`${data.bannerUrl}=w1138`}
  192. alt="채널 배너"
  193. width={1138}
  194. height={190}
  195. className="settings-channel__banner-img"
  196. priority
  197. />
  198. </div>
  199. )}
  200. {/* 프로필 영역 */}
  201. <div className="settings-channel__profile">
  202. {data?.thumbnailUrl ? (
  203. <Image
  204. src={data.thumbnailUrl}
  205. alt={data?.channelName ?? '채널'}
  206. width={80}
  207. height={80}
  208. className="settings-channel__avatar"
  209. />
  210. ) : (
  211. <div className="settings-channel__avatar-placeholder" />
  212. )}
  213. <div className="settings-channel__identity">
  214. <h2 className="settings-channel__name">{data?.channelName}</h2>
  215. <div className="settings-channel__meta">
  216. {data?.handle && <span>{data.handle}</span>}
  217. <span>구독자 {data?.subscriberCount.toLocaleString()}명</span>
  218. <span>동영상 {data?.videoCount.toLocaleString()}개</span>
  219. </div>
  220. </div>
  221. </div>
  222. {/* 상세 정보 */}
  223. <div className="settings-channel__details">
  224. {data?.description && (
  225. <div className="settings-channel__row">
  226. <FontAwesomeIcon icon={faAlignLeft} className="settings-channel__icon" />
  227. <div className="settings-channel__value">
  228. <span className="settings-channel__label">설명</span>
  229. <p className="settings-channel__desc">{data.description}</p>
  230. </div>
  231. </div>
  232. )}
  233. {data?.channelUrl && (
  234. <div className="settings-channel__row">
  235. <FontAwesomeIcon icon={faLink} className="settings-channel__icon" />
  236. <div className="settings-channel__value">
  237. <span className="settings-channel__label">채널 주소</span>
  238. <a
  239. href={data.channelUrl}
  240. target="_blank"
  241. rel="noopener noreferrer"
  242. className="settings-channel__link"
  243. >{data.channelUrl}</a>
  244. </div>
  245. </div>
  246. )}
  247. <div className="settings-channel__row">
  248. <FontAwesomeIcon icon={faGlobe} className="settings-channel__icon" />
  249. <div className="settings-channel__value">
  250. <span className="settings-channel__label">조회수</span>
  251. <span>조회수 {data?.viewCount.toLocaleString()}회</span>
  252. </div>
  253. </div>
  254. {data?.youTubePublishedAt && (
  255. <div className="settings-channel__row">
  256. <FontAwesomeIcon icon={faCalendarDays} className="settings-channel__icon" />
  257. <div className="settings-channel__value">
  258. <span className="settings-channel__label">가입일</span>
  259. <span>{new Date(data.youTubePublishedAt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}</span>
  260. </div>
  261. </div>
  262. )}
  263. <div className="settings-channel__stats">
  264. <div className="settings-channel__stat">
  265. <FontAwesomeIcon icon={faUsers} className="settings-channel__stat-icon" />
  266. <span className="settings-channel__stat-value">{data?.subscriberCount.toLocaleString()}</span>
  267. <span className="settings-channel__stat-label">구독자</span>
  268. </div>
  269. <div className="settings-channel__stat">
  270. <FontAwesomeIcon icon={faVideo} className="settings-channel__stat-icon" />
  271. <span className="settings-channel__stat-value">{data?.videoCount.toLocaleString()}</span>
  272. <span className="settings-channel__stat-label">동영상</span>
  273. </div>
  274. <div className="settings-channel__stat">
  275. <FontAwesomeIcon icon={faEye} className="settings-channel__stat-icon" />
  276. <span className="settings-channel__stat-value">{data?.viewCount.toLocaleString()}</span>
  277. <span className="settings-channel__stat-label">조회수</span>
  278. </div>
  279. </div>
  280. </div>
  281. {/* 연동 해지 */}
  282. {!showDisconnect ? (
  283. <div className="settings-disconnect">
  284. <div className='settings-disconnect__logo'>
  285. <YouTubeSignBoard />
  286. </div>
  287. <p className="settings-disconnect__hint">
  288. YouTube 연동을 해지하면 후원 수신, 라이브 채팅 등의 서비스를 이용할 수 없습니다. <br/>
  289. <button
  290. type="button"
  291. className="settings-disconnect__trigger"
  292. onClick={handleDisconnectClick}
  293. >
  294. 연동 해지하기
  295. </button>
  296. </p>
  297. </div>
  298. ) : (
  299. <div className="settings-disconnect__card">
  300. <p className="settings-disconnect__card-title">YouTube 연동 해지</p>
  301. <p className="settings-disconnect__card-desc">
  302. 연동을 해지하면 아래 서비스를 더 이상 이용할 수 없습니다.
  303. </p>
  304. <ul className="settings-disconnect__impact-list">
  305. <li>후원 수신 (시청자로부터 후원을 받을 수 없습니다)</li>
  306. <li>라이브 채팅 읽기/쓰기</li>
  307. <li>채널 멤버십 확인</li>
  308. <li>채널 정보 자동 동기화</li>
  309. </ul>
  310. <div className="settings-disconnect__terms">
  311. <div className="settings-disconnect__terms-item">
  312. <Checkbox
  313. id="disconnect"
  314. checked={agreedDisconnect}
  315. onCheckedChange={handleAgreedDisconnectChange}
  316. />
  317. <Label htmlFor="disconnect" className="settings-disconnect__terms-label">
  318. <span className="settings-disconnect__terms-required">[필수]</span>
  319. 위 사항을 이해하고 YouTube 연동을 해지합니다.
  320. </Label>
  321. </div>
  322. </div>
  323. <div className="settings-disconnect__actions">
  324. <button
  325. type="button"
  326. className="settings-disconnect__btn settings-disconnect__btn--cancel"
  327. onClick={handleDisconnectCancel}
  328. >
  329. 취소
  330. </button>
  331. <button
  332. type="button"
  333. className="settings-disconnect__btn settings-disconnect__btn--danger"
  334. disabled={!agreedDisconnect || disconnecting}
  335. onClick={handleDisconnect}
  336. >
  337. {disconnecting ? '해지 중...' : '연동 해지'}
  338. </button>
  339. </div>
  340. </div>
  341. )}
  342. </div>
  343. )}
  344. </div>
  345. );
  346. }
  347. function YouTubeIcon() {
  348. return (
  349. <svg viewBox="0 0 24 24" fill="currentColor" width={18} height={18}>
  350. <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"/>
  351. </svg>
  352. );
  353. }