feat(admin): 관리자 대시보드와 미디어 자산 UI를 추가
This commit is contained in:
705
src/app/admin/page.tsx
Normal file
705
src/app/admin/page.tsx
Normal file
@@ -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<AuthResponse | null>(null);
|
||||
const [activeView, setActiveView] = useState<AdminView>('scene');
|
||||
const [loginId, setLoginId] = useState('qwer1234');
|
||||
const [password, setPassword] = useState('qwer1234!');
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginPending, setLoginPending] = useState(false);
|
||||
const [scenePending, setScenePending] = useState(false);
|
||||
const [soundPending, setSoundPending] = useState(false);
|
||||
const [sceneMessage, setSceneMessage] = useState<string | null>(null);
|
||||
const [soundMessage, setSoundMessage] = useState<string | null>(null);
|
||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSession(readStoredSession());
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
|
||||
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col justify-between bg-[#171821] px-8 py-8 text-white">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xl font-semibold tracking-tight">VibeRoom</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Admin Console</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 space-y-8">
|
||||
<div>
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Media Operations
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-3 rounded-xl px-4 py-3 ${
|
||||
index === 0 ? 'bg-white/10 text-white' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-sky-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-slate-500">{item.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Access</p>
|
||||
<p className="mt-3 text-2xl font-semibold">Admin Only</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||
로그인 후 업로드 토큰으로 scene 이미지와 sound 오디오를 바로 R2에 반영합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">
|
||||
Local admin credentials
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<section className="flex items-center justify-center px-6 py-10">
|
||||
<div className="w-full max-w-md rounded-[28px] border border-slate-200 bg-white px-8 py-8 shadow-[0_30px_80px_rgba(15,23,42,0.08)]">
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-600">Sign In</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
관리자 로그인
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">
|
||||
관리자 권한이 확인되면 좌측 사이드바 기반 대시보드가 열립니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<label htmlFor="admin-login-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
아이디
|
||||
</label>
|
||||
<input
|
||||
id="admin-login-id"
|
||||
className={fieldClassName}
|
||||
value={loginId}
|
||||
onChange={(event) => setLoginId(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="qwer1234"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="admin-login-password" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="admin-login-password"
|
||||
type="password"
|
||||
className={fieldClassName}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="qwer1234!"
|
||||
/>
|
||||
</div>
|
||||
{loginError ? (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{loginError}
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="submit" size="full" className="h-12 rounded-xl" disabled={loginPending}>
|
||||
{loginPending ? '로그인 중...' : '대시보드 열기'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f3f4f8] text-slate-900">
|
||||
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="flex flex-col bg-[#171821] px-5 py-6 text-white">
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-fuchsia-500 to-sky-500 text-lg font-bold">
|
||||
V
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tracking-tight">VibeRoom</p>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Media Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-10 flex-1">
|
||||
<div className="px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Media
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.id === activeView;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveView(item.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left transition ${
|
||||
isActive
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-slate-400 hover:bg-white/6 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-8 w-1 rounded-full ${isActive ? 'bg-sky-400' : 'bg-transparent'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
<p className="truncate text-xs text-slate-500">{item.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 px-3">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
|
||||
Session
|
||||
</p>
|
||||
<div className="space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
|
||||
<p className="mt-1 font-medium text-white">{session.user?.name ?? '관리자'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Role</p>
|
||||
<p className="mt-1 font-medium text-emerald-300">{session.user?.grade ?? 'ADMIN'} Access</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/8 px-3 pt-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="full"
|
||||
className="h-11 justify-start rounded-xl bg-white/6 px-4 text-white hover:bg-white/10"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||
<div className="flex min-w-[280px] flex-1 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500"
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<div className="relative w-full max-w-md">
|
||||
<input
|
||||
value={activeView === 'scene' ? 'scene assets' : 'sound assets'}
|
||||
readOnly
|
||||
className="h-11 w-full rounded-xl border border-slate-200 bg-slate-50 pl-11 pr-4 text-sm text-slate-500 outline-none"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
⌕
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden items-center gap-2 rounded-full bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500 md:flex">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Manifest Ready
|
||||
</div>
|
||||
<div className="flex h-11 items-center gap-3 rounded-full border border-slate-200 bg-white px-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">
|
||||
A
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold text-slate-900">{session.user?.name ?? 'Admin'}</p>
|
||||
<p className="text-xs text-slate-500">{session.user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 overflow-auto px-6 py-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="min-w-0 space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_repeat(2,minmax(0,1fr))]">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
|
||||
{activeMeta.eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
{activeMeta.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">
|
||||
{activeMeta.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-sm font-medium text-slate-500">{activeMeta.statTitle}</p>
|
||||
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">{activeMeta.statValue}</p>
|
||||
<p className="mt-2 text-sm text-slate-500">{activeMeta.statHint}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-5 py-5">
|
||||
<p className="text-sm font-medium text-slate-500">Current Role</p>
|
||||
<p className="mt-6 text-3xl font-semibold tracking-tight text-slate-950">
|
||||
{session.user?.grade ?? 'ADMIN'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-500">Bearer token enabled session</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeView === 'scene' ? (
|
||||
<form onSubmit={handleSceneUpload} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">이미지 등록 워크스페이스</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
왼쪽 메뉴는 유지되고, 이 중앙 영역만 `이미지 등록` 작업으로 전환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="scene-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Scene ID
|
||||
</label>
|
||||
<input id="scene-id" name="sceneId" className={fieldClassName} placeholder="rain-window" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Placeholder Gradient
|
||||
</label>
|
||||
<input
|
||||
name="placeholderGradient"
|
||||
className={fieldClassName}
|
||||
placeholder="linear-gradient(160deg, #1e293b 0%, #0f172a 100%)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Card Image</label>
|
||||
<input name="cardFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Stage Image</label>
|
||||
<input name="stageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Mobile Stage</label>
|
||||
<input name="mobileStageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">HD Stage</label>
|
||||
<input name="hdStageFile" type="file" accept="image/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Blur Data URL</label>
|
||||
<textarea
|
||||
name="blurDataUrl"
|
||||
rows={6}
|
||||
className={textareaClassName}
|
||||
placeholder="data:image/jpeg;base64,..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">업로드 메모</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
<li>최초 생성 시 `cardFile`, `stageFile`은 필수입니다.</li>
|
||||
<li>모바일과 HD 파일은 선택값이며 빠지면 기존 값을 유지합니다.</li>
|
||||
<li>업로드 즉시 R2 경로와 manifest 응답 값이 갱신됩니다.</li>
|
||||
</ul>
|
||||
|
||||
{currentMessage ? (
|
||||
<div className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
|
||||
{currentMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={scenePending}
|
||||
>
|
||||
{scenePending ? '이미지 업로드 중...' : '이미지 등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSoundUpload} className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-lg font-semibold text-slate-950">오디오 등록 워크스페이스</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
좌측 네비게이션은 고정되고, 이 중앙 영역만 `오디오 등록` 작업으로 바뀝니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="px-6 py-6">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="preset-id" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
Preset ID
|
||||
</label>
|
||||
<input id="preset-id" name="presetId" className={fieldClassName} placeholder="rain-focus" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Duration Sec</label>
|
||||
<input name="durationSec" type="number" min={0} className={fieldClassName} placeholder="1800" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Default Volume</label>
|
||||
<input name="defaultVolume" type="number" min={0} max={100} className={fieldClassName} placeholder="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Loop File</label>
|
||||
<input name="loopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Preview File</label>
|
||||
<input name="previewFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm font-medium text-slate-700">Fallback Loop File</label>
|
||||
<input name="fallbackLoopFile" type="file" accept="audio/*" className={fileClassName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 bg-slate-50 px-6 py-6 xl:border-l xl:border-t-0">
|
||||
<p className="text-sm font-semibold text-slate-900">업로드 메모</p>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-500">
|
||||
<li>최초 생성 시 `loopFile`은 필수입니다.</li>
|
||||
<li>`preview`, `fallback`은 선택값이며 기존 메타데이터를 유지할 수 있습니다.</li>
|
||||
<li>`defaultVolume`, `durationSec`는 manifest 응답에 함께 반영됩니다.</li>
|
||||
</ul>
|
||||
|
||||
{currentMessage ? (
|
||||
<div className="mt-5 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
|
||||
{currentMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-6 h-12 w-full rounded-xl bg-slate-900 hover:bg-slate-800"
|
||||
disabled={soundPending}
|
||||
>
|
||||
{soundPending ? '오디오 업로드 중...' : '오디오 등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">최근 응답</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm leading-6 text-slate-500">
|
||||
{formatResultSummary(uploadResult)}
|
||||
</p>
|
||||
<pre className="mt-4 max-h-[340px] overflow-auto rounded-xl bg-[#171821] px-4 py-4 text-[11px] leading-6 text-slate-100">
|
||||
{uploadResult ? JSON.stringify(uploadResult, null, 2) : '업로드 응답이 아직 없습니다.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-5 py-4">
|
||||
<p className="text-sm font-semibold text-slate-950">세션 토큰</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="break-all text-xs leading-6 text-slate-500">
|
||||
{session.accessToken}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
26
src/entities/media/api/mediaManifestApi.ts
Normal file
26
src/entities/media/api/mediaManifestApi.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DEFAULT_MEDIA_MANIFEST } from '../model/mockMediaManifest';
|
||||
import { normalizeMediaManifest } from '../model/resolveMediaAsset';
|
||||
import type { MediaManifest } from '../model/types';
|
||||
|
||||
const MEDIA_MANIFEST_URL = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||
|
||||
export const mediaManifestApi = {
|
||||
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
return DEFAULT_MEDIA_MANIFEST;
|
||||
}
|
||||
|
||||
const response = await fetch(MEDIA_MANIFEST_URL, {
|
||||
method: 'GET',
|
||||
cache: 'force-cache',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('미디어 manifest를 불러오지 못했어요.');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Partial<MediaManifest>;
|
||||
return normalizeMediaManifest(payload);
|
||||
},
|
||||
};
|
||||
3
src/entities/media/index.ts
Normal file
3
src/entities/media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './model/types';
|
||||
export * from './model/useMediaCatalog';
|
||||
export * from './model/resolveMediaAsset';
|
||||
26
src/entities/media/model/mockMediaManifest.ts
Normal file
26
src/entities/media/model/mockMediaManifest.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import type { MediaManifest } from './types';
|
||||
|
||||
export const DEFAULT_MEDIA_MANIFEST: MediaManifest = {
|
||||
version: 'local-fallback-v1',
|
||||
updatedAt: '2026-03-09T00:00:00.000Z',
|
||||
cdnBaseUrl: null,
|
||||
scenes: SCENE_THEMES.map((scene) => ({
|
||||
sceneId: scene.id,
|
||||
cardUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
stageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
mobileStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
hdStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
placeholderGradient: scene.previewGradient,
|
||||
})),
|
||||
sounds: SOUND_PRESETS.map((preset) => ({
|
||||
presetId: preset.id,
|
||||
previewUrl: null,
|
||||
loopUrl: null,
|
||||
fallbackLoopUrl: null,
|
||||
mimeType: null,
|
||||
durationSec: null,
|
||||
defaultVolume: null,
|
||||
})),
|
||||
};
|
||||
163
src/entities/media/model/resolveMediaAsset.ts
Normal file
163
src/entities/media/model/resolveMediaAsset.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||
import type {
|
||||
MediaManifest,
|
||||
SceneAssetManifestItem,
|
||||
SceneAssetMap,
|
||||
SoundAssetMap,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)';
|
||||
|
||||
const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
|
||||
|
||||
const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsoluteUrl(value) || value.startsWith('/')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeSceneAssets = (manifest: MediaManifest) => {
|
||||
const bySceneId = new Map(
|
||||
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => [asset.sceneId, asset]),
|
||||
);
|
||||
|
||||
for (const asset of manifest.scenes) {
|
||||
bySceneId.set(asset.sceneId, {
|
||||
...bySceneId.get(asset.sceneId),
|
||||
...asset,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(bySceneId.values()).map((asset) => ({
|
||||
...asset,
|
||||
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl) ?? asset.cardUrl,
|
||||
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl),
|
||||
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl),
|
||||
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl),
|
||||
}));
|
||||
};
|
||||
|
||||
const mergeSoundAssets = (manifest: MediaManifest) => {
|
||||
const byPresetId = new Map(
|
||||
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [asset.presetId, asset]),
|
||||
);
|
||||
|
||||
for (const asset of manifest.sounds) {
|
||||
byPresetId.set(asset.presetId, {
|
||||
...byPresetId.get(asset.presetId),
|
||||
...asset,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byPresetId.values()).map((asset) => ({
|
||||
...asset,
|
||||
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl),
|
||||
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl),
|
||||
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl),
|
||||
}));
|
||||
};
|
||||
|
||||
export const normalizeMediaManifest = (manifest: Partial<MediaManifest> | null | undefined): MediaManifest => {
|
||||
const mergedManifest: MediaManifest = {
|
||||
version: manifest?.version ?? DEFAULT_MEDIA_MANIFEST.version,
|
||||
updatedAt: manifest?.updatedAt ?? DEFAULT_MEDIA_MANIFEST.updatedAt,
|
||||
cdnBaseUrl: manifest?.cdnBaseUrl ?? DEFAULT_MEDIA_MANIFEST.cdnBaseUrl,
|
||||
scenes: manifest?.scenes ?? [],
|
||||
sounds: manifest?.sounds ?? [],
|
||||
};
|
||||
|
||||
return {
|
||||
...mergedManifest,
|
||||
scenes: mergeSceneAssets(mergedManifest),
|
||||
sounds: mergeSoundAssets(mergedManifest),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => {
|
||||
return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => {
|
||||
accumulator[asset.sceneId] = asset;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const buildSoundAssetMap = (manifest: MediaManifest): SoundAssetMap => {
|
||||
return manifest.sounds.reduce<SoundAssetMap>((accumulator, asset) => {
|
||||
accumulator[asset.presetId] = asset;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getSceneCardPhotoUrl = (scene: SceneTheme, asset?: SceneAssetManifestItem | null) => {
|
||||
return asset?.cardUrl ?? scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
|
||||
};
|
||||
|
||||
export const getSceneStagePhotoUrl = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
options?: { preferMobile?: boolean },
|
||||
) => {
|
||||
if (asset) {
|
||||
if (options?.preferMobile && asset.mobileStageUrl) {
|
||||
return asset.mobileStageUrl;
|
||||
}
|
||||
|
||||
if (asset.stageUrl) {
|
||||
return asset.stageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return getSceneCardPhotoUrl(scene, asset);
|
||||
};
|
||||
|
||||
export const getSceneCardBackgroundStyle = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
): CSSProperties => {
|
||||
return {
|
||||
backgroundImage: `url('${getSceneCardPhotoUrl(scene, asset)}')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
};
|
||||
};
|
||||
|
||||
export const getSceneStageBackgroundStyle = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
options?: { preferMobile?: boolean },
|
||||
): CSSProperties => {
|
||||
const stageUrl = getSceneStagePhotoUrl(scene, asset, options);
|
||||
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
|
||||
|
||||
return {
|
||||
backgroundImage: `url('${stageUrl}'), ${placeholderGradient}`,
|
||||
backgroundSize: 'cover, cover',
|
||||
backgroundPosition: 'center, center',
|
||||
backgroundRepeat: 'no-repeat, no-repeat',
|
||||
};
|
||||
};
|
||||
|
||||
export const preloadAssetImage = (url: string | null | undefined) => {
|
||||
if (!url || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new window.Image();
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
};
|
||||
30
src/entities/media/model/types.ts
Normal file
30
src/entities/media/model/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface SceneAssetManifestItem {
|
||||
sceneId: string;
|
||||
cardUrl: string;
|
||||
stageUrl?: string | null;
|
||||
mobileStageUrl?: string | null;
|
||||
hdStageUrl?: string | null;
|
||||
placeholderGradient?: string | null;
|
||||
blurDataUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface SoundAssetManifestItem {
|
||||
presetId: string;
|
||||
previewUrl?: string | null;
|
||||
loopUrl?: string | null;
|
||||
fallbackLoopUrl?: string | null;
|
||||
mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null;
|
||||
durationSec?: number | null;
|
||||
defaultVolume?: number | null;
|
||||
}
|
||||
|
||||
export interface MediaManifest {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
cdnBaseUrl?: string | null;
|
||||
scenes: SceneAssetManifestItem[];
|
||||
sounds: SoundAssetManifestItem[];
|
||||
}
|
||||
|
||||
export type SceneAssetMap = Record<string, SceneAssetManifestItem>;
|
||||
export type SoundAssetMap = Record<string, SoundAssetManifestItem>;
|
||||
60
src/entities/media/model/useMediaCatalog.ts
Normal file
60
src/entities/media/model/useMediaCatalog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { mediaManifestApi } from '../api/mediaManifestApi';
|
||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||
import {
|
||||
buildSceneAssetMap,
|
||||
buildSoundAssetMap,
|
||||
normalizeMediaManifest,
|
||||
} from './resolveMediaAsset';
|
||||
import type { MediaManifest } from './types';
|
||||
|
||||
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
||||
let manifestRequest: Promise<MediaManifest> | null = null;
|
||||
|
||||
const readMediaManifest = async (signal?: AbortSignal) => {
|
||||
if (!process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL) {
|
||||
return manifestCache;
|
||||
}
|
||||
|
||||
if (!manifestRequest) {
|
||||
manifestRequest = mediaManifestApi
|
||||
.getManifest(signal)
|
||||
.then((manifest) => {
|
||||
manifestCache = manifest;
|
||||
return manifest;
|
||||
})
|
||||
.catch(() => manifestCache)
|
||||
.finally(() => {
|
||||
manifestRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
return manifestRequest;
|
||||
};
|
||||
|
||||
export const useMediaCatalog = () => {
|
||||
const [manifest, setManifest] = useState<MediaManifest>(manifestCache);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void readMediaManifest(controller.signal).then((nextManifest) => {
|
||||
setManifest(nextManifest);
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]);
|
||||
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
|
||||
|
||||
return {
|
||||
manifest,
|
||||
sceneAssetMap,
|
||||
soundAssetMap,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -5,7 +5,8 @@ import type { PlanTier } from '@/entities/plan';
|
||||
import {
|
||||
PRO_FEATURE_CARDS,
|
||||
} from '@/entities/plan';
|
||||
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
@@ -14,6 +15,7 @@ import { Toggle } from '@/shared/ui';
|
||||
interface ControlCenterSheetWidgetProps {
|
||||
plan: PlanTier;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
@@ -41,6 +43,7 @@ const SectionTitle = ({ title, description }: { title: string; description: stri
|
||||
export const ControlCenterSheetWidget = ({
|
||||
plan,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
@@ -95,13 +98,17 @@ export const ControlCenterSheetWidget = ({
|
||||
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
||||
)}
|
||||
>
|
||||
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getSceneCardBackgroundStyle(scene)} />
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
||||
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/90">{scene.name}</p>
|
||||
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||
@@ -13,6 +14,7 @@ type RitualPopover = 'space' | 'timer' | 'sound';
|
||||
interface SpaceSetupDrawerWidgetProps {
|
||||
open: boolean;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
@@ -64,6 +66,7 @@ const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
|
||||
export const SpaceSetupDrawerWidget = ({
|
||||
open,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
@@ -207,6 +210,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
<SceneSelectCarousel
|
||||
scenes={scenes.slice(0, 4)}
|
||||
selectedSceneId={selectedSceneId}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
onSelect={(sceneId) => {
|
||||
onSceneSelect(sceneId);
|
||||
setOpenPopover(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||
@@ -20,6 +21,7 @@ import { InboxToolPanel } from './panels/InboxToolPanel';
|
||||
interface SpaceToolsDockWidgetProps {
|
||||
isFocusMode: boolean;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
timerPresets: TimerPreset[];
|
||||
@@ -48,6 +50,7 @@ interface SpaceToolsDockWidgetProps {
|
||||
export const SpaceToolsDockWidget = ({
|
||||
isFocusMode,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
timerPresets,
|
||||
@@ -496,6 +499,7 @@ export const SpaceToolsDockWidget = ({
|
||||
<ControlCenterSheetWidget
|
||||
plan={plan}
|
||||
scenes={scenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedSceneId}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
getSceneBackgroundStyle,
|
||||
getSceneById,
|
||||
SCENE_THEMES,
|
||||
} from '@/entities/scene';
|
||||
import {
|
||||
getSceneStageBackgroundStyle,
|
||||
getSceneStagePhotoUrl,
|
||||
preloadAssetImage,
|
||||
useMediaCatalog,
|
||||
} from '@/entities/media';
|
||||
import {
|
||||
GOAL_CHIPS,
|
||||
SOUND_PRESETS,
|
||||
@@ -16,7 +21,7 @@ import {
|
||||
type TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||
@@ -197,6 +202,8 @@ export const SpaceWorkspaceWidget = () => {
|
||||
timer: false,
|
||||
});
|
||||
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
||||
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
||||
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
||||
|
||||
const {
|
||||
selectedPresetId,
|
||||
@@ -223,6 +230,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const selectedScene = useMemo(() => {
|
||||
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
|
||||
}, [selectedSceneId]);
|
||||
const selectedSceneAsset = sceneAssetMap[selectedScene.id];
|
||||
|
||||
const setupScenes = useMemo(() => {
|
||||
const visibleScenes = SCENE_THEMES.slice(0, 6);
|
||||
@@ -242,6 +250,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
|
||||
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
|
||||
const canRestartSession = Boolean(currentSession);
|
||||
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
|
||||
|
||||
const applyRecommendedSelections = useCallback((
|
||||
sceneId: string,
|
||||
@@ -343,6 +352,21 @@ export const SpaceWorkspaceWidget = () => {
|
||||
};
|
||||
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferMobile =
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
|
||||
|
||||
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
|
||||
}, [selectedScene, selectedSceneAsset]);
|
||||
|
||||
const { error: soundPlaybackError } = useSoundPlayback({
|
||||
selectedPresetId,
|
||||
soundAsset: soundAssetMap[selectedPresetId],
|
||||
masterVolume,
|
||||
isMuted,
|
||||
shouldPlay: shouldPlaySound,
|
||||
});
|
||||
|
||||
const handleSelectScene = (sceneId: string) => {
|
||||
setSelectedSceneId(sceneId);
|
||||
applyRecommendedSelections(sceneId);
|
||||
@@ -601,12 +625,28 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
}, [isFocusMode, pushStatusLine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!soundPlaybackError) {
|
||||
lastSoundPlaybackErrorRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (soundPlaybackError === lastSoundPlaybackErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSoundPlaybackErrorRef.current = soundPlaybackError;
|
||||
pushStatusLine({
|
||||
message: soundPlaybackError,
|
||||
});
|
||||
}, [pushStatusLine, soundPlaybackError]);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
|
||||
style={getSceneBackgroundStyle(selectedScene)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, selectedSceneAsset)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
@@ -616,6 +656,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
<SpaceSetupDrawerWidget
|
||||
open={!isFocusMode}
|
||||
scenes={setupScenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedScene.id}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
@@ -686,6 +727,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
<SpaceToolsDockWidget
|
||||
isFocusMode={isFocusMode}
|
||||
scenes={setupScenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedScene.id}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
timerPresets={TIMER_SELECTION_PRESETS}
|
||||
|
||||
Reference in New Issue
Block a user