page.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. 'use client';
  2. import './style.scss';
  3. import type { CSSProperties, ChangeEvent } from 'react';
  4. import { useCallback, useEffect, useRef, useState } from 'react';
  5. import Image from 'next/image';
  6. import { fetchApi } from '@/lib/utils/client';
  7. import { useStudioContext } from '@/app/studio/context';
  8. import Loading from '@/app/component/Loading';
  9. import { Plus, Pencil, Trash2, Upload, X } from 'lucide-react';
  10. import type { ChannelTitleItem, ChannelTitleListResponse } from '@/types/response/studio/titles';
  11. interface FormData {
  12. id: number|null;
  13. name: string;
  14. description: string;
  15. minAmount: string;
  16. color: string;
  17. iconUrl: string;
  18. isActive: boolean;
  19. }
  20. const EMPTY_FORM: FormData = {
  21. id: null,
  22. name: '',
  23. description: '',
  24. minAmount: '',
  25. color: '#A855F7',
  26. iconUrl: '',
  27. isActive: true
  28. };
  29. const ALLOWED_ICON_MIME = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
  30. const ALLOWED_ICON_EXT = ['.gif', '.jpg', '.jpeg', '.png', '.webp'];
  31. const MAX_ICON_SIZE_MB = 20;
  32. const MAX_ICON_SIZE_BYTES = MAX_ICON_SIZE_MB * 1024 * 1024;
  33. export default function StudioTitlesPage() {
  34. const { channelID } = useStudioContext();
  35. const [titles, setTitles] = useState<ChannelTitleItem[]>([]);
  36. const [loading, setLoading] = useState(true);
  37. const [showForm, setShowForm] = useState(false);
  38. const [form, setForm] = useState<FormData>(EMPTY_FORM);
  39. const [submitting, setSubmitting] = useState(false);
  40. // 아이콘 업로드 관련 상태
  41. const [iconFile, setIconFile] = useState<File|null>(null);
  42. const [iconPreview, setIconPreview] = useState<string|null>(null);
  43. const [uploadingIcon, setUploadingIcon] = useState(false);
  44. const fileInputRef = useRef<HTMLInputElement|null>(null);
  45. const loadTitles = useCallback(() => {
  46. if (!channelID) {
  47. return;
  48. }
  49. setLoading(true);
  50. fetchApi<ChannelTitleListResponse>(`/api/channel-titles/${channelID}?includeInactive=true`, { silent: true }).then((res) => {
  51. setTitles(res.data?.titles ?? []);
  52. }).catch(() => {
  53. setTitles([]);
  54. }).finally(() => {
  55. setLoading(false);
  56. });
  57. }, [channelID]);
  58. useEffect(() => {
  59. loadTitles();
  60. }, [loadTitles]);
  61. // blob URL 누수 방지
  62. useEffect(() => {
  63. return () => {
  64. if (iconPreview && iconPreview.startsWith('blob:')) {
  65. URL.revokeObjectURL(iconPreview);
  66. }
  67. };
  68. }, [iconPreview]);
  69. const resetIconState = () => {
  70. if (iconPreview && iconPreview.startsWith('blob:')) {
  71. URL.revokeObjectURL(iconPreview);
  72. }
  73. setIconFile(null);
  74. setIconPreview(null);
  75. if (fileInputRef.current) {
  76. fileInputRef.current.value = '';
  77. }
  78. };
  79. const openCreateForm = () => {
  80. resetIconState();
  81. setForm(EMPTY_FORM);
  82. setShowForm(true);
  83. };
  84. const openEditForm = (title: ChannelTitleItem) => {
  85. resetIconState();
  86. setForm({
  87. id: title.id,
  88. name: title.name,
  89. description: title.description ?? '',
  90. minAmount: title.minAmount.toString(),
  91. color: title.color,
  92. iconUrl: title.iconUrl ?? '',
  93. isActive: title.isActive
  94. });
  95. setShowForm(true);
  96. };
  97. const closeForm = () => {
  98. resetIconState();
  99. setShowForm(false);
  100. setForm(EMPTY_FORM);
  101. };
  102. const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
  103. const file = e.target.files?.[0];
  104. if (!file) {
  105. return;
  106. }
  107. // 확장자/MIME 검증
  108. const lowerName = file.name.toLowerCase();
  109. const extMatch = ALLOWED_ICON_EXT.some((ext) => lowerName.endsWith(ext));
  110. const mimeMatch = ALLOWED_ICON_MIME.includes(file.type);
  111. if (!extMatch || !mimeMatch) {
  112. alert('이미지 파일(gif, jpeg, png, webp)만 업로드할 수 있습니다.');
  113. if (fileInputRef.current) {
  114. fileInputRef.current.value = '';
  115. }
  116. return;
  117. }
  118. // 크기 검증
  119. if (file.size > MAX_ICON_SIZE_BYTES) {
  120. alert(`파일 크기는 ${MAX_ICON_SIZE_MB}MB 이하여야 합니다.`);
  121. if (fileInputRef.current) {
  122. fileInputRef.current.value = '';
  123. }
  124. return;
  125. }
  126. // 기존 blob 정리 후 새 미리보기 생성
  127. if (iconPreview && iconPreview.startsWith('blob:')) {
  128. URL.revokeObjectURL(iconPreview);
  129. }
  130. const previewUrl = URL.createObjectURL(file);
  131. setIconFile(file);
  132. setIconPreview(previewUrl);
  133. };
  134. const handleRemoveIcon = () => {
  135. resetIconState();
  136. setForm({ ...form, iconUrl: '' });
  137. };
  138. const uploadIcon = async (): Promise<string> => {
  139. if (!iconFile || !channelID) {
  140. throw new Error('업로드할 파일이 없습니다.');
  141. }
  142. const formData = new FormData();
  143. formData.append('file', iconFile);
  144. formData.append('channelID', channelID.toString());
  145. const res = await fetchApi<{ url: string }>('/api/channel-titles/icon/upload', {
  146. method: 'POST',
  147. body: formData
  148. });
  149. if (!res.data?.url) {
  150. throw new Error('아이콘 업로드 응답이 비어 있습니다.');
  151. }
  152. return res.data.url;
  153. };
  154. const handleSubmit = async () => {
  155. if (!channelID) {
  156. return;
  157. }
  158. if (!form.name.trim()) {
  159. alert('칭호 이름을 입력해 주세요.');
  160. return;
  161. }
  162. const amount = parseInt(form.minAmount || '0', 10);
  163. if (Number.isNaN(amount) || amount < 0) {
  164. alert('최소 금액을 올바르게 입력해 주세요.');
  165. return;
  166. }
  167. setSubmitting(true);
  168. try {
  169. let iconUrl = form.iconUrl.trim();
  170. // 새 파일이 있으면 먼저 업로드
  171. if (iconFile) {
  172. setUploadingIcon(true);
  173. try {
  174. iconUrl = await uploadIcon();
  175. } finally {
  176. setUploadingIcon(false);
  177. }
  178. }
  179. const payload = {
  180. ChannelID: channelID,
  181. Name: form.name.trim(),
  182. Description: form.description.trim() || null,
  183. MinAmount: amount,
  184. Color: form.color,
  185. IconUrl: iconUrl || null,
  186. IsActive: form.isActive
  187. };
  188. if (form.id) {
  189. await fetchApi(`/api/channel-titles/${form.id}`, { method: 'PUT', body: payload });
  190. } else {
  191. await fetchApi(`/api/channel-titles`, { method: 'POST', body: payload });
  192. }
  193. alert(form.id ? '칭호가 수정되었습니다.' : '칭호가 추가되었습니다.');
  194. closeForm();
  195. loadTitles();
  196. } catch (err) {
  197. alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
  198. } finally {
  199. setSubmitting(false);
  200. }
  201. };
  202. const handleDelete = (title: ChannelTitleItem) => {
  203. if (!confirm(`"${title.name}" 칭호를 삭제하시겠습니까?`)) {
  204. return;
  205. }
  206. fetchApi(`/api/channel-titles/${title.id}`, { method: 'DELETE' }).then(() => {
  207. alert('삭제되었습니다.');
  208. loadTitles();
  209. }).catch((err) => {
  210. alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
  211. });
  212. };
  213. if (!channelID) {
  214. return (
  215. <div className="studio-page">
  216. <p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
  217. </div>
  218. );
  219. }
  220. // 미리보기 표시용: 새 파일(blob) 우선, 없으면 기존 서버 URL
  221. const previewSrc = iconPreview ?? (form.iconUrl.trim() || null);
  222. return (
  223. <div id="studioTitlesPage">
  224. <header className="studio-titles__header">
  225. <h1>채널 칭호 설정</h1>
  226. <button type="button" className="studio-titles__add-btn" onClick={openCreateForm}>
  227. <Plus size={16} />
  228. 칭호 추가
  229. </button>
  230. </header>
  231. <p className="studio-titles__hint">
  232. 후원자가 누적 후원 금액을 달성하면 자동으로 칭호를 획득합니다. 최대 30개까지 등록할 수 있습니다.
  233. </p>
  234. {loading ? <Loading type={2} /> : (
  235. <div className="studio-titles__list">
  236. {titles.length === 0 ? (
  237. <p className="studio-titles__empty">등록된 칭호가 없습니다.</p>
  238. ) : (
  239. titles.map((title) => (
  240. <article key={title.id} className={`studio-titles__card ${!title.isActive ? 'studio-titles__card--inactive' : ''}`}>
  241. <div className="studio-titles__card-badge" style={{ '--badge-color': title.color } as CSSProperties}>
  242. {title.iconUrl ? (
  243. <Image
  244. src={title.iconUrl}
  245. alt={title.name}
  246. width={20}
  247. height={20}
  248. className="studio-titles__card-badge-icon"
  249. unoptimized
  250. />
  251. ) : null}
  252. <span>{title.name}</span>
  253. </div>
  254. <div className="studio-titles__card-info">
  255. <strong>{title.name}</strong>
  256. <span>누적 {title.minAmount.toLocaleString('ko-KR')}원 이상</span>
  257. {title.description ? <em>{title.description}</em> : null}
  258. </div>
  259. <div className="studio-titles__card-actions">
  260. <button type="button" className="studio-titles__icon-btn" onClick={() => openEditForm(title)} title="편집">
  261. <Pencil size={16} />
  262. </button>
  263. <button type="button" className="studio-titles__icon-btn studio-titles__icon-btn--danger" onClick={() => handleDelete(title)} title="삭제">
  264. <Trash2 size={16} />
  265. </button>
  266. </div>
  267. </article>
  268. ))
  269. )}
  270. </div>
  271. )}
  272. {showForm && (
  273. <div className="studio-titles__form-overlay" role="dialog">
  274. <div className="studio-titles__form">
  275. <h2>{form.id ? '칭호 수정' : '칭호 추가'}</h2>
  276. <label>
  277. 이름
  278. <input
  279. type="text"
  280. value={form.name}
  281. onChange={(e) => setForm({ ...form, name: e.target.value })}
  282. maxLength={20}
  283. placeholder="예) VIP 팬"
  284. />
  285. </label>
  286. <label>
  287. 설명
  288. <input
  289. type="text"
  290. value={form.description}
  291. onChange={(e) => setForm({ ...form, description: e.target.value })}
  292. maxLength={100}
  293. placeholder="설명 (선택)"
  294. />
  295. </label>
  296. <label>
  297. 최소 누적 금액 (원)
  298. <input
  299. type="number"
  300. value={form.minAmount}
  301. onChange={(e) => setForm({ ...form, minAmount: e.target.value })}
  302. min="0"
  303. placeholder="예) 100000"
  304. />
  305. </label>
  306. <label>
  307. 색상
  308. <input
  309. type="color"
  310. value={form.color}
  311. onChange={(e) => setForm({ ...form, color: e.target.value })}
  312. />
  313. </label>
  314. <div className="studio-titles__form-icon">
  315. <span className="studio-titles__form-icon-label">아이콘 (gif, jpeg, png, webp · 최대 {MAX_ICON_SIZE_MB}MB)</span>
  316. <div className="studio-titles__form-icon-row">
  317. <button
  318. type="button"
  319. className="studio-titles__form-icon-btn"
  320. onClick={() => fileInputRef.current?.click()}
  321. disabled={uploadingIcon}
  322. >
  323. <Upload size={14} />
  324. {previewSrc ? '아이콘 변경' : '아이콘 선택'}
  325. </button>
  326. <input
  327. ref={fileInputRef}
  328. type="file"
  329. accept={ALLOWED_ICON_MIME.join(',')}
  330. onChange={handleFileSelect}
  331. className="studio-titles__form-icon-input"
  332. />
  333. {previewSrc ? (
  334. <button
  335. type="button"
  336. className="studio-titles__form-icon-remove"
  337. onClick={handleRemoveIcon}
  338. disabled={uploadingIcon}
  339. title="아이콘 제거"
  340. >
  341. <X size={14} />
  342. 제거
  343. </button>
  344. ) : null}
  345. </div>
  346. {previewSrc ? (
  347. <div className="studio-titles__form-icon-preview">
  348. {/* eslint-disable-next-line @next/next/no-img-element */}
  349. <img src={previewSrc} alt="아이콘 미리보기" />
  350. {iconFile ? (
  351. <small>
  352. {iconFile.name} ({(iconFile.size / 1024).toFixed(1)} KB)
  353. </small>
  354. ) : (
  355. <small>저장된 아이콘</small>
  356. )}
  357. </div>
  358. ) : null}
  359. </div>
  360. <label className="studio-titles__form-checkbox">
  361. <input
  362. type="checkbox"
  363. checked={form.isActive}
  364. onChange={(e) => setForm({ ...form, isActive: e.target.checked })}
  365. />
  366. 활성화
  367. </label>
  368. <div className="studio-titles__form-preview">
  369. <span style={{ '--badge-color': form.color } as CSSProperties}>
  370. {previewSrc ? (
  371. // eslint-disable-next-line @next/next/no-img-element
  372. <img src={previewSrc} alt="" className="studio-titles__form-preview-icon" />
  373. ) : null}
  374. {form.name || '미리보기'}
  375. </span>
  376. 님이 10,000원 후원!
  377. </div>
  378. <div className="studio-titles__form-actions">
  379. <button type="button" onClick={closeForm} disabled={submitting || uploadingIcon}>취소</button>
  380. <button type="button" className="studio-titles__form-save" onClick={handleSubmit} disabled={submitting || uploadingIcon}>
  381. {uploadingIcon
  382. ? '아이콘 업로드 중...'
  383. : submitting
  384. ? (form.id ? '수정 중...' : '등록 중...')
  385. : (form.id ? '수정' : '등록')}
  386. </button>
  387. </div>
  388. </div>
  389. </div>
  390. )}
  391. </div>
  392. );
  393. }