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 { apiClient } from '@/shared/lib/apiClient';
|
||||||
import type { AuthResponse, SocialLoginRequest, UserMeResponse } from '../types';
|
import type { AuthResponse, PasswordLoginRequest, SocialLoginRequest, UserMeResponse } from '../types';
|
||||||
|
|
||||||
interface RefreshTokenResponse {
|
interface RefreshTokenResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -7,6 +7,19 @@ interface RefreshTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
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:
|
* Backend Codex:
|
||||||
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
|
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ export interface SocialLoginRequest {
|
|||||||
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
|
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordLoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
|
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
|
||||||
refreshToken: string; // 토큰 갱신용
|
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';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
interface SceneSelectCarouselProps {
|
interface SceneSelectCarouselProps {
|
||||||
scenes: SceneTheme[];
|
scenes: SceneTheme[];
|
||||||
selectedSceneId: string;
|
selectedSceneId: string;
|
||||||
|
sceneAssetMap?: SceneAssetMap;
|
||||||
onSelect: (sceneId: string) => void;
|
onSelect: (sceneId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SceneSelectCarousel = ({
|
export const SceneSelectCarousel = ({
|
||||||
scenes,
|
scenes,
|
||||||
selectedSceneId,
|
selectedSceneId,
|
||||||
|
sceneAssetMap,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SceneSelectCarouselProps) => {
|
}: SceneSelectCarouselProps) => {
|
||||||
return (
|
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-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',
|
: 'border-white/16 hover:border-white/24',
|
||||||
)}
|
)}
|
||||||
style={getSceneCardBackgroundStyle(scene)}
|
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||||
aria-label={`${scene.name} 선택`}
|
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%)]" />
|
<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 './model/useSoundPresetSelection';
|
||||||
export * from './ui/SoundPresetControls';
|
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 {
|
import {
|
||||||
PRO_FEATURE_CARDS,
|
PRO_FEATURE_CARDS,
|
||||||
} from '@/entities/plan';
|
} 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 { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||||
@@ -14,6 +15,7 @@ import { Toggle } from '@/shared/ui';
|
|||||||
interface ControlCenterSheetWidgetProps {
|
interface ControlCenterSheetWidgetProps {
|
||||||
plan: PlanTier;
|
plan: PlanTier;
|
||||||
scenes: SceneTheme[];
|
scenes: SceneTheme[];
|
||||||
|
sceneAssetMap?: SceneAssetMap;
|
||||||
selectedSceneId: string;
|
selectedSceneId: string;
|
||||||
selectedTimerLabel: string;
|
selectedTimerLabel: string;
|
||||||
selectedSoundPresetId: string;
|
selectedSoundPresetId: string;
|
||||||
@@ -41,6 +43,7 @@ const SectionTitle = ({ title, description }: { title: string; description: stri
|
|||||||
export const ControlCenterSheetWidget = ({
|
export const ControlCenterSheetWidget = ({
|
||||||
plan,
|
plan,
|
||||||
scenes,
|
scenes,
|
||||||
|
sceneAssetMap,
|
||||||
selectedSceneId,
|
selectedSceneId,
|
||||||
selectedTimerLabel,
|
selectedTimerLabel,
|
||||||
selectedSoundPresetId,
|
selectedSoundPresetId,
|
||||||
@@ -96,7 +99,11 @@ export const ControlCenterSheetWidget = ({
|
|||||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
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 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">
|
<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-sm font-medium text-white/90">{scene.name}</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import type { SceneAssetMap } from '@/entities/media';
|
||||||
import type { SceneTheme } from '@/entities/scene';
|
import type { SceneTheme } from '@/entities/scene';
|
||||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||||
@@ -13,6 +14,7 @@ type RitualPopover = 'space' | 'timer' | 'sound';
|
|||||||
interface SpaceSetupDrawerWidgetProps {
|
interface SpaceSetupDrawerWidgetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
scenes: SceneTheme[];
|
scenes: SceneTheme[];
|
||||||
|
sceneAssetMap?: SceneAssetMap;
|
||||||
selectedSceneId: string;
|
selectedSceneId: string;
|
||||||
selectedTimerLabel: string;
|
selectedTimerLabel: string;
|
||||||
selectedSoundPresetId: string;
|
selectedSoundPresetId: string;
|
||||||
@@ -64,6 +66,7 @@ const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
|
|||||||
export const SpaceSetupDrawerWidget = ({
|
export const SpaceSetupDrawerWidget = ({
|
||||||
open,
|
open,
|
||||||
scenes,
|
scenes,
|
||||||
|
sceneAssetMap,
|
||||||
selectedSceneId,
|
selectedSceneId,
|
||||||
selectedTimerLabel,
|
selectedTimerLabel,
|
||||||
selectedSoundPresetId,
|
selectedSoundPresetId,
|
||||||
@@ -207,6 +210,7 @@ export const SpaceSetupDrawerWidget = ({
|
|||||||
<SceneSelectCarousel
|
<SceneSelectCarousel
|
||||||
scenes={scenes.slice(0, 4)}
|
scenes={scenes.slice(0, 4)}
|
||||||
selectedSceneId={selectedSceneId}
|
selectedSceneId={selectedSceneId}
|
||||||
|
sceneAssetMap={sceneAssetMap}
|
||||||
onSelect={(sceneId) => {
|
onSelect={(sceneId) => {
|
||||||
onSceneSelect(sceneId);
|
onSceneSelect(sceneId);
|
||||||
setOpenPopover(null);
|
setOpenPopover(null);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
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 { PlanTier } from '@/entities/plan';
|
||||||
import type { SceneTheme } from '@/entities/scene';
|
import type { SceneTheme } from '@/entities/scene';
|
||||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||||
@@ -20,6 +21,7 @@ import { InboxToolPanel } from './panels/InboxToolPanel';
|
|||||||
interface SpaceToolsDockWidgetProps {
|
interface SpaceToolsDockWidgetProps {
|
||||||
isFocusMode: boolean;
|
isFocusMode: boolean;
|
||||||
scenes: SceneTheme[];
|
scenes: SceneTheme[];
|
||||||
|
sceneAssetMap?: SceneAssetMap;
|
||||||
selectedSceneId: string;
|
selectedSceneId: string;
|
||||||
selectedTimerLabel: string;
|
selectedTimerLabel: string;
|
||||||
timerPresets: TimerPreset[];
|
timerPresets: TimerPreset[];
|
||||||
@@ -48,6 +50,7 @@ interface SpaceToolsDockWidgetProps {
|
|||||||
export const SpaceToolsDockWidget = ({
|
export const SpaceToolsDockWidget = ({
|
||||||
isFocusMode,
|
isFocusMode,
|
||||||
scenes,
|
scenes,
|
||||||
|
sceneAssetMap,
|
||||||
selectedSceneId,
|
selectedSceneId,
|
||||||
selectedTimerLabel,
|
selectedTimerLabel,
|
||||||
timerPresets,
|
timerPresets,
|
||||||
@@ -496,6 +499,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
<ControlCenterSheetWidget
|
<ControlCenterSheetWidget
|
||||||
plan={plan}
|
plan={plan}
|
||||||
scenes={scenes}
|
scenes={scenes}
|
||||||
|
sceneAssetMap={sceneAssetMap}
|
||||||
selectedSceneId={selectedSceneId}
|
selectedSceneId={selectedSceneId}
|
||||||
selectedTimerLabel={selectedTimerLabel}
|
selectedTimerLabel={selectedTimerLabel}
|
||||||
selectedSoundPresetId={selectedPresetId}
|
selectedSoundPresetId={selectedPresetId}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
getSceneBackgroundStyle,
|
|
||||||
getSceneById,
|
getSceneById,
|
||||||
SCENE_THEMES,
|
SCENE_THEMES,
|
||||||
} from '@/entities/scene';
|
} from '@/entities/scene';
|
||||||
|
import {
|
||||||
|
getSceneStageBackgroundStyle,
|
||||||
|
getSceneStagePhotoUrl,
|
||||||
|
preloadAssetImage,
|
||||||
|
useMediaCatalog,
|
||||||
|
} from '@/entities/media';
|
||||||
import {
|
import {
|
||||||
GOAL_CHIPS,
|
GOAL_CHIPS,
|
||||||
SOUND_PRESETS,
|
SOUND_PRESETS,
|
||||||
@@ -16,7 +21,7 @@ import {
|
|||||||
type TimerPreset,
|
type TimerPreset,
|
||||||
} from '@/entities/session';
|
} from '@/entities/session';
|
||||||
import { useFocusSessionEngine } from '@/features/focus-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 { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||||
@@ -197,6 +202,8 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
timer: false,
|
timer: false,
|
||||||
});
|
});
|
||||||
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
||||||
|
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
||||||
|
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
@@ -223,6 +230,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const selectedScene = useMemo(() => {
|
const selectedScene = useMemo(() => {
|
||||||
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
|
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
|
||||||
}, [selectedSceneId]);
|
}, [selectedSceneId]);
|
||||||
|
const selectedSceneAsset = sceneAssetMap[selectedScene.id];
|
||||||
|
|
||||||
const setupScenes = useMemo(() => {
|
const setupScenes = useMemo(() => {
|
||||||
const visibleScenes = SCENE_THEMES.slice(0, 6);
|
const visibleScenes = SCENE_THEMES.slice(0, 6);
|
||||||
@@ -242,6 +250,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
|
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
|
||||||
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
|
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
|
||||||
const canRestartSession = Boolean(currentSession);
|
const canRestartSession = Boolean(currentSession);
|
||||||
|
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
|
||||||
|
|
||||||
const applyRecommendedSelections = useCallback((
|
const applyRecommendedSelections = useCallback((
|
||||||
sceneId: string,
|
sceneId: string,
|
||||||
@@ -343,6 +352,21 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
};
|
};
|
||||||
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
|
}, [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) => {
|
const handleSelectScene = (sceneId: string) => {
|
||||||
setSelectedSceneId(sceneId);
|
setSelectedSceneId(sceneId);
|
||||||
applyRecommendedSelections(sceneId);
|
applyRecommendedSelections(sceneId);
|
||||||
@@ -601,12 +625,28 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
});
|
});
|
||||||
}, [isFocusMode, pushStatusLine]);
|
}, [isFocusMode, pushStatusLine]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!soundPlaybackError) {
|
||||||
|
lastSoundPlaybackErrorRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soundPlaybackError === lastSoundPlaybackErrorRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSoundPlaybackErrorRef.current = soundPlaybackError;
|
||||||
|
pushStatusLine({
|
||||||
|
message: soundPlaybackError,
|
||||||
|
});
|
||||||
|
}, [pushStatusLine, soundPlaybackError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh overflow-hidden text-white">
|
<div className="relative h-dvh overflow-hidden text-white">
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
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"
|
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">
|
<div className="relative z-10 flex h-full flex-col">
|
||||||
@@ -616,6 +656,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
<SpaceSetupDrawerWidget
|
<SpaceSetupDrawerWidget
|
||||||
open={!isFocusMode}
|
open={!isFocusMode}
|
||||||
scenes={setupScenes}
|
scenes={setupScenes}
|
||||||
|
sceneAssetMap={sceneAssetMap}
|
||||||
selectedSceneId={selectedScene.id}
|
selectedSceneId={selectedScene.id}
|
||||||
selectedTimerLabel={selectedTimerLabel}
|
selectedTimerLabel={selectedTimerLabel}
|
||||||
selectedSoundPresetId={selectedPresetId}
|
selectedSoundPresetId={selectedPresetId}
|
||||||
@@ -686,6 +727,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
<SpaceToolsDockWidget
|
<SpaceToolsDockWidget
|
||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
scenes={setupScenes}
|
scenes={setupScenes}
|
||||||
|
sceneAssetMap={sceneAssetMap}
|
||||||
selectedSceneId={selectedScene.id}
|
selectedSceneId={selectedScene.id}
|
||||||
selectedTimerLabel={selectedTimerLabel}
|
selectedTimerLabel={selectedTimerLabel}
|
||||||
timerPresets={TIMER_SELECTION_PRESETS}
|
timerPresets={TIMER_SELECTION_PRESETS}
|
||||||
|
|||||||
Reference in New Issue
Block a user