feat(admin): 관리자 대시보드와 미디어 자산 UI를 추가
This commit is contained in:
@@ -5,7 +5,8 @@ import type { PlanTier } from '@/entities/plan';
|
||||
import {
|
||||
PRO_FEATURE_CARDS,
|
||||
} from '@/entities/plan';
|
||||
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||
import { SOUND_PRESETS, type TimerPreset } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||
@@ -14,6 +15,7 @@ import { Toggle } from '@/shared/ui';
|
||||
interface ControlCenterSheetWidgetProps {
|
||||
plan: PlanTier;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
@@ -41,6 +43,7 @@ const SectionTitle = ({ title, description }: { title: string; description: stri
|
||||
export const ControlCenterSheetWidget = ({
|
||||
plan,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
@@ -95,13 +98,17 @@ export const ControlCenterSheetWidget = ({
|
||||
reducedMotion ? '' : 'hover:-translate-y-0.5',
|
||||
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
||||
)}
|
||||
>
|
||||
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getSceneCardBackgroundStyle(scene)} />
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
||||
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white/90">{scene.name}</p>
|
||||
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import type { GoalChip, SoundPreset, TimerPreset } from '@/entities/session';
|
||||
import { SceneSelectCarousel } from '@/features/scene-select';
|
||||
@@ -13,6 +14,7 @@ type RitualPopover = 'space' | 'timer' | 'sound';
|
||||
interface SpaceSetupDrawerWidgetProps {
|
||||
open: boolean;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
selectedSoundPresetId: string;
|
||||
@@ -64,6 +66,7 @@ const SummaryChip = ({ label, value, open, onClick }: SummaryChipProps) => {
|
||||
export const SpaceSetupDrawerWidget = ({
|
||||
open,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
selectedSoundPresetId,
|
||||
@@ -207,6 +210,7 @@ export const SpaceSetupDrawerWidget = ({
|
||||
<SceneSelectCarousel
|
||||
scenes={scenes.slice(0, 4)}
|
||||
selectedSceneId={selectedSceneId}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
onSelect={(sceneId) => {
|
||||
onSceneSelect(sceneId);
|
||||
setOpenPopover(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import type { SceneAssetMap } from '@/entities/media';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||
@@ -20,6 +21,7 @@ import { InboxToolPanel } from './panels/InboxToolPanel';
|
||||
interface SpaceToolsDockWidgetProps {
|
||||
isFocusMode: boolean;
|
||||
scenes: SceneTheme[];
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
selectedSceneId: string;
|
||||
selectedTimerLabel: string;
|
||||
timerPresets: TimerPreset[];
|
||||
@@ -48,6 +50,7 @@ interface SpaceToolsDockWidgetProps {
|
||||
export const SpaceToolsDockWidget = ({
|
||||
isFocusMode,
|
||||
scenes,
|
||||
sceneAssetMap,
|
||||
selectedSceneId,
|
||||
selectedTimerLabel,
|
||||
timerPresets,
|
||||
@@ -496,6 +499,7 @@ export const SpaceToolsDockWidget = ({
|
||||
<ControlCenterSheetWidget
|
||||
plan={plan}
|
||||
scenes={scenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedSceneId}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
getSceneBackgroundStyle,
|
||||
getSceneById,
|
||||
SCENE_THEMES,
|
||||
} from '@/entities/scene';
|
||||
import {
|
||||
getSceneStageBackgroundStyle,
|
||||
getSceneStagePhotoUrl,
|
||||
preloadAssetImage,
|
||||
useMediaCatalog,
|
||||
} from '@/entities/media';
|
||||
import {
|
||||
GOAL_CHIPS,
|
||||
SOUND_PRESETS,
|
||||
@@ -16,7 +21,7 @@ import {
|
||||
type TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||
@@ -197,6 +202,8 @@ export const SpaceWorkspaceWidget = () => {
|
||||
timer: false,
|
||||
});
|
||||
const queuedFocusStatusMessageRef = useRef<string | null>(null);
|
||||
const lastSoundPlaybackErrorRef = useRef<string | null>(null);
|
||||
const { sceneAssetMap, soundAssetMap } = useMediaCatalog();
|
||||
|
||||
const {
|
||||
selectedPresetId,
|
||||
@@ -223,6 +230,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const selectedScene = useMemo(() => {
|
||||
return getSceneById(selectedSceneId) ?? SCENE_THEMES[0];
|
||||
}, [selectedSceneId]);
|
||||
const selectedSceneAsset = sceneAssetMap[selectedScene.id];
|
||||
|
||||
const setupScenes = useMemo(() => {
|
||||
const visibleScenes = SCENE_THEMES.slice(0, 6);
|
||||
@@ -242,6 +250,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const canStartSession = canStart && (!currentSession || resolvedPlaybackState !== 'running');
|
||||
const canPauseSession = Boolean(currentSession && resolvedPlaybackState === 'running');
|
||||
const canRestartSession = Boolean(currentSession);
|
||||
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
|
||||
|
||||
const applyRecommendedSelections = useCallback((
|
||||
sceneId: string,
|
||||
@@ -343,6 +352,21 @@ export const SpaceWorkspaceWidget = () => {
|
||||
};
|
||||
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const preferMobile =
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
|
||||
|
||||
preloadAssetImage(getSceneStagePhotoUrl(selectedScene, selectedSceneAsset, { preferMobile }));
|
||||
}, [selectedScene, selectedSceneAsset]);
|
||||
|
||||
const { error: soundPlaybackError } = useSoundPlayback({
|
||||
selectedPresetId,
|
||||
soundAsset: soundAssetMap[selectedPresetId],
|
||||
masterVolume,
|
||||
isMuted,
|
||||
shouldPlay: shouldPlaySound,
|
||||
});
|
||||
|
||||
const handleSelectScene = (sceneId: string) => {
|
||||
setSelectedSceneId(sceneId);
|
||||
applyRecommendedSelections(sceneId);
|
||||
@@ -601,12 +625,28 @@ export const SpaceWorkspaceWidget = () => {
|
||||
});
|
||||
}, [isFocusMode, pushStatusLine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!soundPlaybackError) {
|
||||
lastSoundPlaybackErrorRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (soundPlaybackError === lastSoundPlaybackErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSoundPlaybackErrorRef.current = soundPlaybackError;
|
||||
pushStatusLine({
|
||||
message: soundPlaybackError,
|
||||
});
|
||||
}, [pushStatusLine, soundPlaybackError]);
|
||||
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
|
||||
style={getSceneBackgroundStyle(selectedScene)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, selectedSceneAsset)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
@@ -616,6 +656,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
<SpaceSetupDrawerWidget
|
||||
open={!isFocusMode}
|
||||
scenes={setupScenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedScene.id}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
selectedSoundPresetId={selectedPresetId}
|
||||
@@ -686,6 +727,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
<SpaceToolsDockWidget
|
||||
isFocusMode={isFocusMode}
|
||||
scenes={setupScenes}
|
||||
sceneAssetMap={sceneAssetMap}
|
||||
selectedSceneId={selectedScene.id}
|
||||
selectedTimerLabel={selectedTimerLabel}
|
||||
timerPresets={TIMER_SELECTION_PRESETS}
|
||||
|
||||
Reference in New Issue
Block a user