From d5f194e7b1d45d5722c2e38128873f83163c13d1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 15:32:05 +0900 Subject: [PATCH] =?UTF-8?q?chore(phase1):=20admin/packs=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(API=EB=8A=94=20products=C2=B7mypage=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EB=A1=9C=20=EC=9C=A0=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE: app/admin/packs 페이지 디렉토리 전체 제거 - MODIFY: AdminSidebar.tsx에서 '팩 자료' 네비게이션 항목 제거 - VERIFY: /api/admin/packs API 엔드포인트는 유지 (products·mypage 공유) - VERIFY: npm test (4 passed) + npm run build (success, no errors) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/components/AdminSidebar.tsx | 10 - app/admin/packs/page.tsx | 257 -------------------------- 2 files changed, 267 deletions(-) delete mode 100644 app/admin/packs/page.tsx diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index f237c0d..4b48f9f 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -76,16 +76,6 @@ const NAV_ITEMS = [ ), }, - { - href: '/admin/packs', - label: '팩 자료', - icon: ( - - - - ), - }, { href: '/admin/marketing', label: '광고 관리', diff --git a/app/admin/packs/page.tsx b/app/admin/packs/page.tsx deleted file mode 100644 index 85bce05..0000000 --- a/app/admin/packs/page.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import Link from 'next/link'; - -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)} - -
- ))} -
- )} -
- )) - )} -
- ); -}