diff --git a/app/admin/packs/page.tsx b/app/admin/packs/page.tsx new file mode 100644 index 0000000..fa98663 --- /dev/null +++ b/app/admin/packs/page.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type PackTier = 'starter' | 'pro' | 'master'; + +interface PackFile { + id: string; + min_tier: PackTier; + label: string; + filename: string; + size_bytes: number; + sort_order: number; + uploaded_at: string; +} + +const TIER_LABEL: Record = { + starter: '입문', + pro: '프로', + master: '마스터', +}; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; +} + +export default function AdminPacksPage() { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + + // 업로드 form state + const [tier, setTier] = useState('starter'); + const [label, setLabel] = useState(''); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + async function loadFiles() { + setLoading(true); + try { + const res = await fetch('/api/admin/packs'); + const data = await res.json(); + setFiles(data.files ?? []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + } + + useEffect(() => { loadFiles(); }, []); + + async function handleUpload(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!file || !label) return; + setUploading(true); + setProgress(0); + + try { + // 1) Vercel API에서 일회성 토큰 발급 + const tokenRes = await fetch('/api/admin/packs/upload-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tier, + label, + filename: file.name, + sizeBytes: file.size, + }), + }); + if (!tokenRes.ok) { + const err = await tokenRes.json(); + throw new Error(err.error ?? '토큰 발급 실패'); + } + const { token, uploadUrl } = await tokenRes.json(); + + // 2) 브라우저가 web-backend에 직접 multipart POST (XHR로 진행률 추적) + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable) { + setProgress(Math.round((ev.loaded / ev.total) * 100)); + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else { + try { + const { detail } = JSON.parse(xhr.responseText); + reject(new Error(detail ?? `HTTP ${xhr.status}`)); + } catch { + reject(new Error(`HTTP ${xhr.status}`)); + } + } + }; + xhr.onerror = () => reject(new Error('네트워크 오류')); + const fd = new FormData(); + fd.append('file', file); + xhr.send(fd); + }); + + // 3) 리스트 갱신 + setFile(null); + setLabel(''); + setProgress(0); + await loadFiles(); + } catch (e) { + setError(e instanceof Error ? e.message : '업로드 실패'); + } finally { + setUploading(false); + } + } + + async function handleDelete(id: string, label: string) { + if (!confirm(`"${label}" 자료를 삭제하시겠습니까?`)) return; + try { + const res = await fetch(`/api/admin/packs?id=${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('삭제 실패'); + await loadFiles(); + } catch (e) { + alert(e instanceof Error ? e.message : '삭제 실패'); + } + } + + async function handlePatch(id: string, updates: Partial) { + try { + await fetch('/api/admin/packs', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...updates }), + }); + await loadFiles(); + } catch (e) { + console.error(e); + } + } + + const grouped: Record = { + starter: files.filter((f) => f.min_tier === 'starter'), + pro: files.filter((f) => f.min_tier === 'pro'), + master: files.filter((f) => f.min_tier === 'master'), + }; + + return ( +
+
+

팩 자료 관리

+

+ NAS 자료 업로드 + 다운로드 활성화. 최대 5GB / 4시간 만료 공유 링크. +

+
+ + {/* 업로드 폼 */} +
+

새 자료 업로드

+
+ + setLabel(e.target.value)} + disabled={uploading} + placeholder="자료 라벨 (예: Suno 프롬프트 북 PDF)" + className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 md:col-span-2" + /> +
+ setFile(e.target.files?.[0] ?? null)} + disabled={uploading} + className="text-slate-300 mb-3 block" + /> + {file && ( +

+ 선택됨: {file.name} ({formatSize(file.size)}) +

+ )} + {uploading && ( +
+
+
+
+

{progress}% 업로드 중... 페이지를 닫지 마세요

+
+ )} + {error &&

{error}

} + + + + {/* 자료 리스트 */} + {loading ? ( +

불러오는 중...

+ ) : ( + (['starter', 'pro', 'master'] as PackTier[]).map((t) => ( +
+

+ {TIER_LABEL[t]} ({grouped[t].length}) +

+ {grouped[t].length === 0 ? ( +

자료 없음

+ ) : ( +
+ {grouped[t].map((f) => ( +
+ { + if (e.target.value !== f.label) handlePatch(f.id, { label: e.target.value }); + }} + className="flex-1 bg-transparent text-white border-b border-transparent focus:border-slate-500 px-1 py-1" + /> + {f.filename} + {formatSize(f.size_bytes)} + +
+ ))} +
+ )} +
+ )) + )} +
+ ); +}