page.tsx 13 KB

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