feat(admin): 관리자 대시보드와 미디어 자산 UI를 추가

This commit is contained in:
2026-03-09 20:09:10 +09:00
parent cceaa6bd82
commit 986b9ba94b
17 changed files with 1413 additions and 10 deletions

View File

@@ -0,0 +1,144 @@
import type { AuthResponse } from '@/features/auth/types';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
interface ApiEnvelope<T> {
data: T;
message?: string;
error?: string;
}
interface ApiErrorPayload {
message?: string;
error?: string;
}
export interface SceneMediaAssetUploadResponse {
sceneId: string;
cardObjectKey: string;
cardUrl: string;
stageObjectKey: string;
stageUrl: string;
mobileStageObjectKey?: string | null;
mobileStageUrl?: string | null;
hdStageObjectKey?: string | null;
hdStageUrl?: string | null;
placeholderGradient?: string | null;
blurDataUrl?: string | null;
assetVersion: string;
updatedAt: string;
}
export interface SoundMediaAssetUploadResponse {
presetId: string;
previewObjectKey?: string | null;
previewUrl?: string | null;
loopObjectKey: string;
loopUrl: string;
fallbackLoopObjectKey?: string | null;
fallbackLoopUrl?: string | null;
mimeType?: string | null;
durationSec?: number | null;
defaultVolume?: number | null;
assetVersion: string;
updatedAt: string;
}
export interface AdminLoginInput {
loginId: string;
password: string;
}
const buildUrl = (endpoint: string) => {
return `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
};
const parseErrorMessage = async (response: Response) => {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
return `요청 실패: ${response.status}`;
}
try {
const payload = (await response.json()) as ApiErrorPayload;
return payload.message ?? payload.error ?? `요청 실패: ${response.status}`;
} catch {
return `요청 실패: ${response.status}`;
}
};
const unwrapPayload = <T>(payload: T | ApiEnvelope<T>) => {
if (payload && typeof payload === 'object' && 'data' in payload) {
return payload.data;
}
return payload as T;
};
const fetchWithToken = async <T>(
endpoint: string,
accessToken: string,
body: FormData,
): Promise<T> => {
const response = await fetch(buildUrl(endpoint), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body,
});
if (!response.ok) {
throw new Error(await parseErrorMessage(response));
}
const payload = (await response.json()) as T | ApiEnvelope<T>;
return unwrapPayload(payload);
};
export const adminApi = {
login: async ({ loginId, password }: AdminLoginInput): Promise<AuthResponse> => {
const response = await fetch(buildUrl('/api/v1/auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: loginId,
password,
}),
});
if (!response.ok) {
throw new Error(await parseErrorMessage(response));
}
const payload = (await response.json()) as AuthResponse | ApiEnvelope<AuthResponse>;
return unwrapPayload(payload);
},
uploadScene: async (
sceneId: string,
formData: FormData,
accessToken: string,
): Promise<SceneMediaAssetUploadResponse> => {
return fetchWithToken<SceneMediaAssetUploadResponse>(
`/api/v1/admin/media/scenes/${encodeURIComponent(sceneId)}`,
accessToken,
formData,
);
},
uploadSound: async (
presetId: string,
formData: FormData,
accessToken: string,
): Promise<SoundMediaAssetUploadResponse> => {
return fetchWithToken<SoundMediaAssetUploadResponse>(
`/api/v1/admin/media/sounds/${encodeURIComponent(presetId)}`,
accessToken,
formData,
);
},
};

View File

@@ -1,5 +1,5 @@
import { apiClient } from '@/shared/lib/apiClient';
import type { AuthResponse, SocialLoginRequest, UserMeResponse } from '../types';
import type { AuthResponse, PasswordLoginRequest, SocialLoginRequest, UserMeResponse } from '../types';
interface RefreshTokenResponse {
accessToken: string;
@@ -7,6 +7,19 @@ interface RefreshTokenResponse {
}
export const authApi = {
/**
* Backend Codex:
* - 로컬 계정(email/password)으로 로그인하고 VibeRoom access/refresh token을 발급한다.
* - 응답에는 accessToken, refreshToken, user를 포함한다.
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 권한 UI를 판별할 수 있어야 한다.
*/
loginWithPassword: async (data: PasswordLoginRequest): Promise<AuthResponse> => {
return apiClient<AuthResponse>('api/v1/auth/login', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Backend Codex:
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.

View File

@@ -3,6 +3,11 @@ export interface SocialLoginRequest {
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
}
export interface PasswordLoginRequest {
email: string;
password: string;
}
export interface AuthResponse {
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
refreshToken: string; // 토큰 갱신용

View File

@@ -1,15 +1,18 @@
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
import type { SceneTheme } from '@/entities/scene';
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
import { cn } from '@/shared/lib/cn';
interface SceneSelectCarouselProps {
scenes: SceneTheme[];
selectedSceneId: string;
sceneAssetMap?: SceneAssetMap;
onSelect: (sceneId: string) => void;
}
export const SceneSelectCarousel = ({
scenes,
selectedSceneId,
sceneAssetMap,
onSelect,
}: SceneSelectCarouselProps) => {
return (
@@ -29,7 +32,7 @@ export const SceneSelectCarousel = ({
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
: 'border-white/16 hover:border-white/24',
)}
style={getSceneCardBackgroundStyle(scene)}
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
aria-label={`${scene.name} 선택`}
>
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />

View File

@@ -1,2 +1,3 @@
export * from './model/useSoundPlayback';
export * from './model/useSoundPresetSelection';
export * from './ui/SoundPresetControls';

View File

@@ -0,0 +1,167 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { SoundAssetManifestItem } from '@/entities/media';
interface UseSoundPlaybackOptions {
selectedPresetId: string;
soundAsset?: SoundAssetManifestItem;
masterVolume: number;
isMuted: boolean;
shouldPlay: boolean;
}
const clampVolume = (value: number) => {
return Math.min(1, Math.max(0, value / 100));
};
export const useSoundPlayback = ({
selectedPresetId,
soundAsset,
masterVolume,
isMuted,
shouldPlay,
}: UseSoundPlaybackOptions) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isReady, setReady] = useState(false);
const [isPlaying, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeUrl = useMemo(() => {
if (selectedPresetId === 'silent') {
return null;
}
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const audio = new window.Audio();
audio.loop = true;
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
const handleCanPlay = () => {
setReady(true);
setError(null);
};
const handlePlaying = () => {
setPlaying(true);
};
const handlePause = () => {
setPlaying(false);
};
const handleLoadStart = () => {
setReady(false);
setError(null);
};
const handleError = () => {
setReady(false);
setPlaying(false);
setError('사운드 파일을 불러오지 못했어요.');
};
audio.addEventListener('loadstart', handleLoadStart);
audio.addEventListener('canplay', handleCanPlay);
audio.addEventListener('playing', handlePlaying);
audio.addEventListener('pause', handlePause);
audio.addEventListener('error', handleError);
audioRef.current = audio;
return () => {
audio.pause();
audio.removeAttribute('src');
audio.load();
audio.removeEventListener('loadstart', handleLoadStart);
audio.removeEventListener('canplay', handleCanPlay);
audio.removeEventListener('playing', handlePlaying);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('error', handleError);
audioRef.current = null;
};
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
audio.volume = clampVolume(masterVolume);
audio.muted = isMuted;
}, [isMuted, masterVolume]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (!activeUrl) {
audio.pause();
audio.removeAttribute('src');
audio.load();
return;
}
if (audio.src === activeUrl) {
return;
}
audio.src = activeUrl;
audio.load();
}, [activeUrl]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) {
return;
}
if (!shouldPlay || !activeUrl) {
audio.pause();
return;
}
let cancelled = false;
const playAudio = async () => {
try {
await audio.play();
if (!cancelled) {
setError(null);
}
} catch {
if (!cancelled) {
setPlaying(false);
setError('브라우저가 사운드 재생을 보류했어요.');
}
}
};
void playAudio();
return () => {
cancelled = true;
};
}, [activeUrl, shouldPlay]);
return {
activeUrl,
isReady,
isPlaying,
error,
};
};