From 986b9ba94ba63ea3b1b8a07ea6bc5535e2e7b788 Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 9 Mar 2026 20:09:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=99=80=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=EC=9E=90=EC=82=B0=20UI=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 705 ++++++++++++++++++ src/entities/media/api/mediaManifestApi.ts | 26 + src/entities/media/index.ts | 3 + src/entities/media/model/mockMediaManifest.ts | 26 + src/entities/media/model/resolveMediaAsset.ts | 163 ++++ src/entities/media/model/types.ts | 30 + src/entities/media/model/useMediaCatalog.ts | 60 ++ src/features/admin/api/adminApi.ts | 144 ++++ src/features/auth/api/authApi.ts | 15 +- src/features/auth/types/index.ts | 5 + .../scene-select/ui/SceneSelectCarousel.tsx | 7 +- src/features/sound-preset/index.ts | 1 + .../sound-preset/model/useSoundPlayback.ts | 167 +++++ .../ui/ControlCenterSheetWidget.tsx | 15 +- .../ui/SpaceSetupDrawerWidget.tsx | 4 + .../ui/SpaceToolsDockWidget.tsx | 4 + .../ui/SpaceWorkspaceWidget.tsx | 48 +- 17 files changed, 1413 insertions(+), 10 deletions(-) create mode 100644 src/app/admin/page.tsx create mode 100644 src/entities/media/api/mediaManifestApi.ts create mode 100644 src/entities/media/index.ts create mode 100644 src/entities/media/model/mockMediaManifest.ts create mode 100644 src/entities/media/model/resolveMediaAsset.ts create mode 100644 src/entities/media/model/types.ts create mode 100644 src/entities/media/model/useMediaCatalog.ts create mode 100644 src/features/admin/api/adminApi.ts create mode 100644 src/features/sound-preset/model/useSoundPlayback.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..1bd1f82 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,705 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; +import { adminApi, type SceneMediaAssetUploadResponse, type SoundMediaAssetUploadResponse } from '@/features/admin/api/adminApi'; +import type { AuthResponse } from '@/features/auth/types'; +import { Button } from '@/shared/ui'; + +const ADMIN_STORAGE_KEY = 'vr_admin_session'; + +type AdminView = 'scene' | 'sound'; + +type UploadResult = + | { type: 'scene'; payload: SceneMediaAssetUploadResponse } + | { type: 'sound'; payload: SoundMediaAssetUploadResponse }; + +type NavItem = { + id: AdminView; + title: string; + subtitle: string; + section: string; +}; + +const navItems: NavItem[] = [ + { + id: 'scene', + title: '이미지 등록', + subtitle: 'Scene background assets', + section: 'MEDIA', + }, + { + id: 'sound', + title: '오디오 등록', + subtitle: 'Loop and preview assets', + section: 'MEDIA', + }, +]; + +const readStoredSession = (): AuthResponse | null => { + if (typeof window === 'undefined') { + return null; + } + + const rawValue = window.localStorage.getItem(ADMIN_STORAGE_KEY); + if (!rawValue) { + return null; + } + + try { + return JSON.parse(rawValue) as AuthResponse; + } catch { + window.localStorage.removeItem(ADMIN_STORAGE_KEY); + return null; + } +}; + +const storeSession = (session: AuthResponse | null) => { + if (typeof window === 'undefined') { + return; + } + + if (!session) { + window.localStorage.removeItem(ADMIN_STORAGE_KEY); + return; + } + + window.localStorage.setItem(ADMIN_STORAGE_KEY, JSON.stringify(session)); +}; + +const fieldClassName = + 'h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100'; + +const fileClassName = + 'block w-full rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:font-medium file:text-white hover:file:bg-slate-800'; + +const textareaClassName = + 'min-h-32 w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100'; + +const getViewMeta = (view: AdminView) => { + if (view === 'scene') { + return { + eyebrow: 'Image Registration', + title: 'Scene 이미지 등록', + description: + 'Space에 들어온 사용자가 선택하는 배경 이미지 세트를 등록합니다. cardFile과 stageFile은 최초 생성 시 필수입니다.', + accent: 'sky', + statTitle: 'Image Pipeline', + statValue: 'Scene Assets', + statHint: 'R2 / Manifest Sync', + }; + } + + return { + eyebrow: 'Audio Registration', + title: 'Sound 오디오 등록', + description: + 'Loop, preview, fallback 오디오를 등록합니다. loopFile은 최초 생성 시 필수이며 기본 볼륨과 duration 메타데이터를 함께 저장합니다.', + accent: 'emerald', + statTitle: 'Audio Pipeline', + statValue: 'Sound Assets', + statHint: 'Loop / Preview / Fallback', + }; +}; + +const formatResultSummary = (uploadResult: UploadResult | null) => { + if (!uploadResult) { + return '아직 업로드 작업이 없습니다.'; + } + + if (uploadResult.type === 'scene') { + return `${uploadResult.payload.sceneId} 이미지 세트가 최신 버전 ${uploadResult.payload.assetVersion}로 반영되었습니다.`; + } + + return `${uploadResult.payload.presetId} 오디오 세트가 최신 버전 ${uploadResult.payload.assetVersion}로 반영되었습니다.`; +}; + +export default function AdminPage() { + const [session, setSession] = useState(null); + const [activeView, setActiveView] = useState('scene'); + const [loginId, setLoginId] = useState('qwer1234'); + const [password, setPassword] = useState('qwer1234!'); + const [loginError, setLoginError] = useState(null); + const [loginPending, setLoginPending] = useState(false); + const [scenePending, setScenePending] = useState(false); + const [soundPending, setSoundPending] = useState(false); + const [sceneMessage, setSceneMessage] = useState(null); + const [soundMessage, setSoundMessage] = useState(null); + const [uploadResult, setUploadResult] = useState(null); + + useEffect(() => { + setSession(readStoredSession()); + }, []); + + const handleLogin = async (event: FormEvent) => { + event.preventDefault(); + setLoginPending(true); + setLoginError(null); + + try { + const response = await adminApi.login({ + loginId: loginId.trim(), + password, + }); + + if (response.user?.grade !== 'ADMIN') { + throw new Error('ADMIN 권한이 없는 계정입니다.'); + } + + setSession(response); + storeSession(response); + } catch (error) { + setLoginError(error instanceof Error ? error.message : '로그인에 실패했습니다.'); + } finally { + setLoginPending(false); + } + }; + + const handleLogout = () => { + setSession(null); + setActiveView('scene'); + setSceneMessage(null); + setSoundMessage(null); + setUploadResult(null); + storeSession(null); + }; + + const handleSceneUpload = async (event: FormEvent) => { + event.preventDefault(); + if (!session?.accessToken) { + setSceneMessage('먼저 관리자 로그인을 해주세요.'); + return; + } + + setScenePending(true); + setSceneMessage(null); + + try { + const form = event.currentTarget; + const source = new FormData(form); + const sceneId = String(source.get('sceneId') ?? '').trim(); + + if (!sceneId) { + throw new Error('sceneId를 입력해주세요.'); + } + + const formData = new FormData(); + for (const fieldName of ['cardFile', 'stageFile', 'mobileStageFile', 'hdStageFile']) { + const file = source.get(fieldName); + if (file instanceof File && file.size > 0) { + formData.append(fieldName, file); + } + } + + for (const fieldName of ['placeholderGradient', 'blurDataUrl']) { + const value = String(source.get(fieldName) ?? '').trim(); + if (value) { + formData.append(fieldName, value); + } + } + + const response = await adminApi.uploadScene(sceneId, formData, session.accessToken); + setUploadResult({ type: 'scene', payload: response }); + setSceneMessage(`scene "${sceneId}" 업로드가 완료되었습니다.`); + form.reset(); + } catch (error) { + setSceneMessage(error instanceof Error ? error.message : 'scene 업로드에 실패했습니다.'); + } finally { + setScenePending(false); + } + }; + + const handleSoundUpload = async (event: FormEvent) => { + event.preventDefault(); + if (!session?.accessToken) { + setSoundMessage('먼저 관리자 로그인을 해주세요.'); + return; + } + + setSoundPending(true); + setSoundMessage(null); + + try { + const form = event.currentTarget; + const source = new FormData(form); + const presetId = String(source.get('presetId') ?? '').trim(); + + if (!presetId) { + throw new Error('presetId를 입력해주세요.'); + } + + const formData = new FormData(); + for (const fieldName of ['loopFile', 'previewFile', 'fallbackLoopFile']) { + const file = source.get(fieldName); + if (file instanceof File && file.size > 0) { + formData.append(fieldName, file); + } + } + + for (const fieldName of ['durationSec', 'defaultVolume']) { + const value = String(source.get(fieldName) ?? '').trim(); + if (value) { + formData.append(fieldName, value); + } + } + + const response = await adminApi.uploadSound(presetId, formData, session.accessToken); + setUploadResult({ type: 'sound', payload: response }); + setSoundMessage(`sound "${presetId}" 업로드가 완료되었습니다.`); + form.reset(); + } catch (error) { + setSoundMessage(error instanceof Error ? error.message : 'sound 업로드에 실패했습니다.'); + } finally { + setSoundPending(false); + } + }; + + const activeMeta = getViewMeta(activeView); + const currentMessage = activeView === 'scene' ? sceneMessage : soundMessage; + + if (!session) { + return ( +
+
+ + +
+
+
+

Sign In

+

+ 관리자 로그인 +

+

+ 관리자 권한이 확인되면 좌측 사이드바 기반 대시보드가 열립니다. +

+
+ +
+
+ + setLoginId(event.target.value)} + autoComplete="username" + placeholder="qwer1234" + /> +
+
+ + setPassword(event.target.value)} + autoComplete="current-password" + placeholder="qwer1234!" + /> +
+ {loginError ? ( +
+ {loginError} +
+ ) : null} + +
+
+
+
+
+ ); + } + + return ( +
+
+ + +
+
+
+
+ +
+ + + ⌕ + +
+
+ +
+
+ + Manifest Ready +
+
+
+ A +
+
+

{session.user?.name ?? 'Admin'}

+

{session.user?.email}

+
+
+
+
+
+ +
+
+
+
+
+

+ {activeMeta.eyebrow} +

+

+ {activeMeta.title} +

+

+ {activeMeta.description} +

+
+
+

{activeMeta.statTitle}

+

{activeMeta.statValue}

+

{activeMeta.statHint}

+
+
+

Current Role

+

+ {session.user?.grade ?? 'ADMIN'} +

+

Bearer token enabled session

+
+
+ + {activeView === 'scene' ? ( +
+
+

이미지 등록 워크스페이스

+

+ 왼쪽 메뉴는 유지되고, 이 중앙 영역만 `이미지 등록` 작업으로 전환됩니다. +

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +