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

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

705
src/app/admin/page.tsx Normal file
View 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>
);
}

View 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);
},
};

View File

@@ -0,0 +1,3 @@
export * from './model/types';
export * from './model/useMediaCatalog';
export * from './model/resolveMediaAsset';

View 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,
})),
};

View 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;
};

View 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>;

View 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,
};
};

View File

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

View File

@@ -1,5 +1,5 @@
import { apiClient } from '@/shared/lib/apiClient'; import { 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으로 교환한다.

View File

@@ -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; // 토큰 갱신용

View File

@@ -1,15 +1,18 @@
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene'; import type { SceneTheme } from '@/entities/scene';
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
import { cn } from '@/shared/lib/cn'; 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%)]" />

View File

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

View File

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

View File

@@ -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,
@@ -95,13 +98,17 @@ export const ControlCenterSheetWidget = ({
reducedMotion ? '' : 'hover:-translate-y-0.5', reducedMotion ? '' : 'hover:-translate-y-0.5',
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>
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p> <p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
</div> </div>
</button> </button>
); );
})} })}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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}