'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)}
))}
)}
)) )}
); }