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

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

View File

@@ -5,7 +5,8 @@ import type { PlanTier } from '@/entities/plan';
import {
PRO_FEATURE_CARDS,
} from '@/entities/plan';
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
import type { SceneTheme } from '@/entities/scene';
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
@@ -14,6 +15,7 @@ import { Toggle } from '@/shared/ui';
interface ControlCenterSheetWidgetProps {
plan: PlanTier;
scenes: SceneTheme[];
sceneAssetMap?: SceneAssetMap;
selectedSceneId: string;
selectedTimerLabel: string;
selectedSoundPresetId: string;
@@ -41,6 +43,7 @@ const SectionTitle = ({ title, description }: { title: string; description: stri
export const ControlCenterSheetWidget = ({
plan,
scenes,
sceneAssetMap,
selectedSceneId,
selectedTimerLabel,
selectedSoundPresetId,
@@ -95,13 +98,17 @@ export const ControlCenterSheetWidget = ({
reducedMotion ? '' : 'hover:-translate-y-0.5',
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
)}
>
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getSceneCardBackgroundStyle(scene)} />
>
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center"
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
/>
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
<div className="absolute inset-x-2 bottom-2 min-w-0">
<p className="truncate text-sm font-medium text-white/90">{scene.name}</p>
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
</div>
</div>
</button>
);
})}

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import type { SceneAssetMap } from '@/entities/media';
import type { SceneTheme } from '@/entities/scene';
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
import { SceneSelectCarousel } from '@/features/scene-select';
@@ -13,6 +14,7 @@ type RitualPopover = 'space' | 'timer' | 'sound';
interface SpaceSetupDrawerWidgetProps {
open: boolean;
scenes: SceneTheme[];
sceneAssetMap?: SceneAssetMap;
selectedSceneId: string;
selectedTimerLabel: string;
selectedSoundPresetId: string;
@@ -64,6 +66,7 @@ const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
export const SpaceSetupDrawerWidget = ({
open,
scenes,
sceneAssetMap,
selectedSceneId,
selectedTimerLabel,
selectedSoundPresetId,
@@ -207,6 +210,7 @@ export const SpaceSetupDrawerWidget = ({
<SceneSelectCarousel
scenes={scenes.slice(0, 4)}
selectedSceneId={selectedSceneId}
sceneAssetMap={sceneAssetMap}
onSelect={(sceneId) => {
onSceneSelect(sceneId);
setOpenPopover(null);

View File

@@ -1,5 +1,6 @@
'use client';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SceneAssetMap } from '@/entities/media';
import type { PlanTier } from '@/entities/plan';
import type { SceneTheme } from '@/entities/scene';
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
@@ -20,6 +21,7 @@ import { InboxToolPanel } from './panels/InboxToolPanel';
interface SpaceToolsDockWidgetProps {
isFocusMode: boolean;
scenes: SceneTheme[];
sceneAssetMap?: SceneAssetMap;
selectedSceneId: string;
selectedTimerLabel: string;
timerPresets: TimerPreset[];
@@ -48,6 +50,7 @@ interface SpaceToolsDockWidgetProps {
export const SpaceToolsDockWidget = ({
isFocusMode,
scenes,
sceneAssetMap,
selectedSceneId,
selectedTimerLabel,
timerPresets,
@@ -496,6 +499,7 @@ export const SpaceToolsDockWidget = ({
<ControlCenterSheetWidget
plan={plan}
scenes={scenes}
sceneAssetMap={sceneAssetMap}
selectedSceneId={selectedSceneId}
selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}

View File

@@ -3,10 +3,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import {
getSceneBackgroundStyle,
getSceneById,
SCENE_THEMES,
} from '@/entities/scene';
import {
getSceneStageBackgroundStyle,
getSceneStagePhotoUrl,
preloadAssetImage,
useMediaCatalog,
} from '@/entities/media';
import {
GOAL_CHIPS,
SOUND_PRESETS,
@@ -16,7 +21,7 @@ import {
type TimerPreset,
} from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPresetSelection } from '@/features/sound-preset';
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
@@ -197,6 +202,8 @@ export const SpaceWorkspaceWidget = () => {
timer: false,
});
const queuedFocusStatusMessageRef = useRef<string | null>(null);
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
const {
selectedPresetId,
@@ -223,6 +230,7 @@ export const SpaceWorkspaceWidget = () => {
const selectedScene = useMemo(() => {
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
}, [selectedSceneId]);
const selectedSceneAsset = sceneAssetMap[selectedScene.id];
const setupScenes = useMemo(() => {
const visibleScenes = SCENE_THEMES.slice(0, 6);
@@ -242,6 +250,7 @@ export const SpaceWorkspaceWidget = () => {
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
const canRestartSession = Boolean(currentSession);
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
const applyRecommendedSelections = useCallback((
sceneId: string,
@@ -343,6 +352,21 @@ export const SpaceWorkspaceWidget = () => {
};
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
useEffect(() => {
const preferMobile =
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
}, [selectedScene, selectedSceneAsset]);
const { error: soundPlaybackError } = useSoundPlayback({
selectedPresetId,
soundAsset: soundAssetMap[selectedPresetId],
masterVolume,
isMuted,
shouldPlay: shouldPlaySound,
});
const handleSelectScene = (sceneId: string) => {
setSelectedSceneId(sceneId);
applyRecommendedSelections(sceneId);
@@ -601,12 +625,28 @@ export const SpaceWorkspaceWidget = () => {
});
}, [isFocusMode, pushStatusLine]);
useEffect(() => {
if (!soundPlaybackError) {
lastSoundPlaybackErrorRef.current = null;
return;
}
if (soundPlaybackError === lastSoundPlaybackErrorRef.current) {
return;
}
lastSoundPlaybackErrorRef.current = soundPlaybackError;
pushStatusLine({
message: soundPlaybackError,
});
}, [pushStatusLine, soundPlaybackError]);
return (
<div className="relative h-dvh overflow-hidden text-white">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
style={getSceneBackgroundStyle(selectedScene)}
style={getSceneStageBackgroundStyle(selectedScene, selectedSceneAsset)}
/>
<div className="relative z-10 flex h-full flex-col">
@@ -616,6 +656,7 @@ export const SpaceWorkspaceWidget = () => {
<SpaceSetupDrawerWidget
open={!isFocusMode}
scenes={setupScenes}
sceneAssetMap={sceneAssetMap}
selectedSceneId={selectedScene.id}
selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}
@@ -686,6 +727,7 @@ export const SpaceWorkspaceWidget = () => {
<SpaceToolsDockWidget
isFocusMode={isFocusMode}
scenes={setupScenes}
sceneAssetMap={sceneAssetMap}
selectedSceneId={selectedScene.id}
selectedTimerLabel={selectedTimerLabel}
timerPresets={TIMER_SELECTION_PRESETS}