feat(admin): 관리자 대시보드와 미디어 자산 UI를 추가
This commit is contained in:
144
src/features/admin/api/adminApi.ts
Normal file
144
src/features/admin/api/adminApi.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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으로 교환한다.
|
||||
|
||||
@@ -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; // 토큰 갱신용
|
||||
|
||||
@@ -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%)]" />
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './model/useSoundPlayback';
|
||||
export * from './model/useSoundPresetSelection';
|
||||
export * from './ui/SoundPresetControls';
|
||||
|
||||
167
src/features/sound-preset/model/useSoundPlayback.ts
Normal file
167
src/features/sound-preset/model/useSoundPlayback.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user