feat(sound): 우하단 빠른 볼륨/3프리셋 및 더미 저장 복원 적용
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
|
||||
const TRACK_KEYS = ['white', 'rain', 'cafe', 'wave', 'fan'] as const;
|
||||
@@ -16,24 +16,93 @@ const DEFAULT_TRACK_LEVELS: Record<SoundTrackKey, number> = {
|
||||
};
|
||||
|
||||
const DEFAULT_MASTER_VOLUME = 68;
|
||||
const SOUND_PREF_STORAGE_KEY = 'viberoom:sound-pref:v1';
|
||||
|
||||
const clampVolume = (value: number) => {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
};
|
||||
|
||||
interface StoredSoundPref {
|
||||
selectedPresetId?: string;
|
||||
masterVolume?: number;
|
||||
isMuted?: boolean;
|
||||
}
|
||||
|
||||
const readStoredSoundPref = (): StoredSoundPref => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(SOUND_PREF_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return parsed as StoredSoundPref;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const useSoundPresetSelection = (initialPresetId?: string) => {
|
||||
const storedPref = useMemo(() => readStoredSoundPref(), []);
|
||||
|
||||
const safeInitialPresetId = useMemo(() => {
|
||||
const hasPreset = SOUND_PRESETS.some((preset) => preset.id === initialPresetId);
|
||||
return hasPreset && initialPresetId ? initialPresetId : SOUND_PRESETS[0].id;
|
||||
}, [initialPresetId]);
|
||||
const candidates = [initialPresetId, storedPref.selectedPresetId];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SOUND_PRESETS.some((preset) => preset.id === candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return SOUND_PRESETS[0].id;
|
||||
}, [initialPresetId, storedPref.selectedPresetId]);
|
||||
|
||||
const [selectedPresetId, setSelectedPresetId] = useState(safeInitialPresetId);
|
||||
const [isMixerOpen, setMixerOpen] = useState(false);
|
||||
const [isMuted, setMuted] = useState(false);
|
||||
const [masterVolume, setMasterVolume] = useState(DEFAULT_MASTER_VOLUME);
|
||||
const [isMuted, setMuted] = useState(Boolean(storedPref.isMuted));
|
||||
const [masterVolume, setMasterVolume] = useState(
|
||||
clampVolume(storedPref.masterVolume ?? DEFAULT_MASTER_VOLUME),
|
||||
);
|
||||
const [trackLevels, setTrackLevels] =
|
||||
useState<Record<SoundTrackKey, number>>(DEFAULT_TRACK_LEVELS);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
SOUND_PREF_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
selectedPresetId,
|
||||
masterVolume: clampVolume(masterVolume),
|
||||
isMuted,
|
||||
}),
|
||||
);
|
||||
}, [selectedPresetId, masterVolume, isMuted]);
|
||||
|
||||
const setTrackLevel = (track: SoundTrackKey, level: number) => {
|
||||
setTrackLevels((current) => ({ ...current, [track]: level }));
|
||||
};
|
||||
|
||||
const setMasterVolumeSafe = (nextVolume: number) => {
|
||||
setMasterVolume(clampVolume(nextVolume));
|
||||
};
|
||||
|
||||
return {
|
||||
selectedPresetId,
|
||||
setSelectedPresetId,
|
||||
@@ -42,7 +111,7 @@ export const useSoundPresetSelection = (initialPresetId?: string) => {
|
||||
isMuted,
|
||||
setMuted,
|
||||
masterVolume,
|
||||
setMasterVolume,
|
||||
setMasterVolume: setMasterVolumeSafe,
|
||||
trackLevels,
|
||||
setTrackLevel,
|
||||
trackKeys: TRACK_KEYS,
|
||||
|
||||
@@ -11,10 +11,8 @@ const isQuickAvailablePreset = (preset: SoundPreset | undefined): preset is Soun
|
||||
return !PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
||||
};
|
||||
|
||||
export const getQuickSoundPresets = (presets: SoundPreset[], selectedPresetId: string) => {
|
||||
const uniquePresetIds = Array.from(new Set([selectedPresetId, ...QUICK_SOUND_FALLBACK_IDS]));
|
||||
|
||||
return uniquePresetIds
|
||||
export const getQuickSoundPresets = (presets: SoundPreset[]) => {
|
||||
return QUICK_SOUND_FALLBACK_IDS
|
||||
.map((presetId) => presets.find((preset) => preset.id === presetId))
|
||||
.filter(isQuickAvailablePreset)
|
||||
.slice(0, 3);
|
||||
|
||||
53
src/widgets/space-tools-dock/ui/FocusRightRail.tsx
Normal file
53
src/widgets/space-tools-dock/ui/FocusRightRail.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { formatThoughtCount, RAIL_ICON } from './constants';
|
||||
|
||||
interface FocusRightRailProps {
|
||||
isIdle: boolean;
|
||||
thoughtCount: number;
|
||||
onOpenInbox: () => void;
|
||||
onOpenControlCenter: () => void;
|
||||
}
|
||||
|
||||
export const FocusRightRail = ({
|
||||
isIdle,
|
||||
thoughtCount,
|
||||
onOpenInbox,
|
||||
onOpenControlCenter,
|
||||
}: FocusRightRailProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
|
||||
isIdle ? 'opacity-34' : 'opacity-78',
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="인박스 열기"
|
||||
title="인박스"
|
||||
onClick={onOpenInbox}
|
||||
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
{RAIL_ICON.inbox}
|
||||
{thoughtCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
|
||||
{formatThoughtCount(thoughtCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Quick Controls 열기"
|
||||
title="Quick Controls"
|
||||
onClick={onOpenControlCenter}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
{RAIL_ICON.controlCenter}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import type { RoomTheme } from '@/entities/room';
|
||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||
@@ -13,7 +13,10 @@ import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
||||
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
|
||||
import { applyQuickPack } from '../model/applyQuickPack';
|
||||
import { getQuickSoundPresets } from '../model/getQuickSoundPresets';
|
||||
import { ANCHOR_ICON, formatThoughtCount, RAIL_ICON, UTILITY_PANEL_TITLE } from './constants';
|
||||
import { ANCHOR_ICON, formatThoughtCount, UTILITY_PANEL_TITLE } from './constants';
|
||||
import { FocusRightRail } from './FocusRightRail';
|
||||
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
|
||||
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
|
||||
import { InboxToolPanel } from './panels/InboxToolPanel';
|
||||
interface SpaceToolsDockWidgetProps {
|
||||
isFocusMode: boolean;
|
||||
@@ -22,6 +25,10 @@ interface SpaceToolsDockWidgetProps {
|
||||
selectedTimerLabel: string;
|
||||
timerPresets: TimerPreset[];
|
||||
selectedPresetId: string;
|
||||
soundVolume: number;
|
||||
onSetSoundVolume: (volume: number) => void;
|
||||
isSoundMuted: boolean;
|
||||
onSetSoundMuted: (nextMuted: boolean) => void;
|
||||
thoughts: RecentThought[];
|
||||
thoughtCount: number;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
@@ -43,6 +50,10 @@ export const SpaceToolsDockWidget = ({
|
||||
selectedTimerLabel,
|
||||
timerPresets,
|
||||
selectedPresetId,
|
||||
soundVolume,
|
||||
onSetSoundVolume,
|
||||
isSoundMuted,
|
||||
onSetSoundMuted,
|
||||
thoughts,
|
||||
thoughtCount,
|
||||
onRoomSelect,
|
||||
@@ -60,8 +71,10 @@ export const SpaceToolsDockWidget = ({
|
||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
||||
const [noteDraft, setNoteDraft] = useState('');
|
||||
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
|
||||
const [plan, setPlan] = useState<PlanTier>('normal');
|
||||
const [isIdle, setIdle] = useState(false);
|
||||
const volumeFeedbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
const selectedSoundLabel = useMemo(() => {
|
||||
return (
|
||||
@@ -70,8 +83,17 @@ export const SpaceToolsDockWidget = ({
|
||||
}, [selectedPresetId]);
|
||||
|
||||
const quickSoundPresets = useMemo(() => {
|
||||
return getQuickSoundPresets(SOUND_PRESETS, selectedPresetId);
|
||||
}, [selectedPresetId]);
|
||||
return getQuickSoundPresets(SOUND_PRESETS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (volumeFeedbackTimerRef.current) {
|
||||
window.clearTimeout(volumeFeedbackTimerRef.current);
|
||||
volumeFeedbackTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openPopover) {
|
||||
@@ -167,15 +189,6 @@ export const SpaceToolsDockWidget = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleNoteKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
handleNoteSubmit();
|
||||
};
|
||||
|
||||
const handleInboxComplete = (thought: RecentThought) => {
|
||||
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
||||
|
||||
@@ -262,6 +275,42 @@ export const SpaceToolsDockWidget = ({
|
||||
const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
|
||||
applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast });
|
||||
|
||||
const showVolumeFeedback = (nextVolume: number) => {
|
||||
setVolumeFeedback(`${nextVolume}%`);
|
||||
|
||||
if (volumeFeedbackTimerRef.current) {
|
||||
window.clearTimeout(volumeFeedbackTimerRef.current);
|
||||
}
|
||||
|
||||
volumeFeedbackTimerRef.current = window.setTimeout(() => {
|
||||
setVolumeFeedback(null);
|
||||
volumeFeedbackTimerRef.current = null;
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (nextVolume: number) => {
|
||||
const clamped = Math.min(100, Math.max(0, nextVolume));
|
||||
onSetSoundVolume(clamped);
|
||||
|
||||
if (isSoundMuted && clamped > 0) {
|
||||
onSetSoundMuted(false);
|
||||
}
|
||||
|
||||
showVolumeFeedback(clamped);
|
||||
};
|
||||
|
||||
const handleVolumeKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const step = event.shiftKey ? 10 : 5;
|
||||
const delta = event.key === 'ArrowRight' ? step : -step;
|
||||
handleVolumeChange(soundVolume + delta);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{openPopover ? (
|
||||
@@ -287,40 +336,12 @@ export const SpaceToolsDockWidget = ({
|
||||
|
||||
{isFocusMode ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
|
||||
isIdle ? 'opacity-34' : 'opacity-78',
|
||||
)}
|
||||
>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="인박스 열기"
|
||||
title="인박스"
|
||||
onClick={() => openUtilityPanel('inbox')}
|
||||
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
{RAIL_ICON.inbox}
|
||||
{thoughtCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
|
||||
{formatThoughtCount(thoughtCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Quick Controls 열기"
|
||||
title="Quick Controls"
|
||||
onClick={() => openUtilityPanel('control-center')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||
>
|
||||
{RAIL_ICON.controlCenter}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FocusRightRail
|
||||
isIdle={isIdle}
|
||||
thoughtCount={thoughtCount}
|
||||
onOpenInbox={() => openUtilityPanel('inbox')}
|
||||
onOpenControlCenter={() => openUtilityPanel('control-center')}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
@@ -344,29 +365,12 @@ export const SpaceToolsDockWidget = ({
|
||||
</button>
|
||||
|
||||
{openPopover === 'notes' ? (
|
||||
<div
|
||||
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">떠오른 생각을 잠깐 주차해요</p>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<input
|
||||
value={noteDraft}
|
||||
onChange={(event) => setNoteDraft(event.target.value)}
|
||||
onKeyDown={handleNoteKeyDown}
|
||||
placeholder="떠오른 생각을 잠깐 주차…"
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
||||
<QuickNotesPopover
|
||||
noteDraft={noteDraft}
|
||||
onDraftChange={setNoteDraft}
|
||||
onDraftEnter={handleNoteSubmit}
|
||||
onSubmit={handleNoteSubmit}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNoteSubmit}
|
||||
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-white/52">나중에 인박스에서 정리해요.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,37 +397,26 @@ export const SpaceToolsDockWidget = ({
|
||||
</button>
|
||||
|
||||
{openPopover === 'sound' ? (
|
||||
<div
|
||||
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">빠른 사운드 전환</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{quickSoundPresets.map((preset) => {
|
||||
const selected = preset.id === selectedPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectPreset(preset.id);
|
||||
pushToast({ title: `${preset.label}로 전환했어요.` });
|
||||
<QuickSoundPopover
|
||||
selectedSoundLabel={selectedSoundLabel}
|
||||
isSoundMuted={isSoundMuted}
|
||||
soundVolume={soundVolume}
|
||||
volumeFeedback={volumeFeedback}
|
||||
quickSoundPresets={quickSoundPresets}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onToggleMute={() => {
|
||||
const nextMuted = !isSoundMuted;
|
||||
onSetSoundMuted(nextMuted);
|
||||
showVolumeFeedback(nextMuted ? 0 : soundVolume);
|
||||
}}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
onVolumeKeyDown={handleVolumeKeyDown}
|
||||
onSelectPreset={(presetId) => {
|
||||
onSelectPreset(presetId);
|
||||
pushToast({ title: '사운드 변경(더미)' });
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
interface QuickNotesPopoverProps {
|
||||
noteDraft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onDraftEnter: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const QuickNotesPopover = ({
|
||||
noteDraft,
|
||||
onDraftChange,
|
||||
onDraftEnter,
|
||||
onSubmit,
|
||||
}: QuickNotesPopoverProps) => {
|
||||
return (
|
||||
<div
|
||||
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">떠오른 생각을 잠깐 주차해요</p>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<input
|
||||
value={noteDraft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onDraftEnter();
|
||||
}}
|
||||
placeholder="떠오른 생각을 잠깐 주차…"
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-white/52">나중에 인박스에서 정리해요.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import type { SoundPreset } from '@/entities/session';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
|
||||
interface QuickSoundPopoverProps {
|
||||
selectedSoundLabel: string;
|
||||
isSoundMuted: boolean;
|
||||
soundVolume: number;
|
||||
volumeFeedback: string | null;
|
||||
quickSoundPresets: SoundPreset[];
|
||||
selectedPresetId: string;
|
||||
onToggleMute: () => void;
|
||||
onVolumeChange: (nextVolume: number) => void;
|
||||
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
|
||||
onSelectPreset: (presetId: string) => void;
|
||||
}
|
||||
|
||||
export const QuickSoundPopover = ({
|
||||
selectedSoundLabel,
|
||||
isSoundMuted,
|
||||
soundVolume,
|
||||
volumeFeedback,
|
||||
quickSoundPresets,
|
||||
selectedPresetId,
|
||||
onToggleMute,
|
||||
onVolumeChange,
|
||||
onVolumeKeyDown,
|
||||
onSelectPreset,
|
||||
}: QuickSoundPopoverProps) => {
|
||||
return (
|
||||
<div
|
||||
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
|
||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
|
||||
>
|
||||
<p className="text-[11px] text-white/56">현재 사운드</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
|
||||
|
||||
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isSoundMuted ? '음소거 해제' : '음소거'}
|
||||
onClick={onToggleMute}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
|
||||
>
|
||||
🔇
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={soundVolume}
|
||||
onChange={(event) => onVolumeChange(Number(event.target.value))}
|
||||
onKeyDown={onVolumeKeyDown}
|
||||
aria-label="사운드 볼륨"
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200"
|
||||
/>
|
||||
<span className="w-9 text-right text-[11px] text-white/66">
|
||||
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-white/56">빠른 전환</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{quickSoundPresets.map((preset) => {
|
||||
const selected = preset.id === selectedPresetId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPreset(preset.id)}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
|
||||
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -80,6 +80,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const {
|
||||
selectedPresetId,
|
||||
setSelectedPresetId,
|
||||
masterVolume,
|
||||
setMasterVolume,
|
||||
isMuted,
|
||||
setMuted,
|
||||
} = useSoundPresetSelection(initialSoundPresetId);
|
||||
|
||||
const selectedRoom = useMemo(() => {
|
||||
@@ -212,6 +216,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onRoomSelect={setSelectedRoomId}
|
||||
onTimerSelect={setSelectedTimerLabel}
|
||||
onSelectPreset={setSelectedPresetId}
|
||||
soundVolume={masterVolume}
|
||||
onSetSoundVolume={setMasterVolume}
|
||||
isSoundMuted={isMuted}
|
||||
onSetSoundMuted={setMuted}
|
||||
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
|
||||
onDeleteThought={removeThought}
|
||||
onSetThoughtCompleted={setThoughtCompleted}
|
||||
|
||||
Reference in New Issue
Block a user