feat(sound): 우하단 빠른 볼륨/3프리셋 및 더미 저장 복원 적용
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { SOUND_PRESETS } from '@/entities/session';
|
import { SOUND_PRESETS } from '@/entities/session';
|
||||||
|
|
||||||
const TRACK_KEYS = ['white', 'rain', 'cafe', 'wave', 'fan'] as const;
|
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 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) => {
|
export const useSoundPresetSelection = (initialPresetId?: string) => {
|
||||||
|
const storedPref = useMemo(() => readStoredSoundPref(), []);
|
||||||
|
|
||||||
const safeInitialPresetId = useMemo(() => {
|
const safeInitialPresetId = useMemo(() => {
|
||||||
const hasPreset = SOUND_PRESETS.some((preset) => preset.id === initialPresetId);
|
const candidates = [initialPresetId, storedPref.selectedPresetId];
|
||||||
return hasPreset && initialPresetId ? initialPresetId : SOUND_PRESETS[0].id;
|
|
||||||
}, [initialPresetId]);
|
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 [selectedPresetId, setSelectedPresetId] = useState(safeInitialPresetId);
|
||||||
const [isMixerOpen, setMixerOpen] = useState(false);
|
const [isMixerOpen, setMixerOpen] = useState(false);
|
||||||
const [isMuted, setMuted] = useState(false);
|
const [isMuted, setMuted] = useState(Boolean(storedPref.isMuted));
|
||||||
const [masterVolume, setMasterVolume] = useState(DEFAULT_MASTER_VOLUME);
|
const [masterVolume, setMasterVolume] = useState(
|
||||||
|
clampVolume(storedPref.masterVolume ?? DEFAULT_MASTER_VOLUME),
|
||||||
|
);
|
||||||
const [trackLevels, setTrackLevels] =
|
const [trackLevels, setTrackLevels] =
|
||||||
useState<Record<SoundTrackKey, number>>(DEFAULT_TRACK_LEVELS);
|
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) => {
|
const setTrackLevel = (track: SoundTrackKey, level: number) => {
|
||||||
setTrackLevels((current) => ({ ...current, [track]: level }));
|
setTrackLevels((current) => ({ ...current, [track]: level }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setMasterVolumeSafe = (nextVolume: number) => {
|
||||||
|
setMasterVolume(clampVolume(nextVolume));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
setSelectedPresetId,
|
setSelectedPresetId,
|
||||||
@@ -42,7 +111,7 @@ export const useSoundPresetSelection = (initialPresetId?: string) => {
|
|||||||
isMuted,
|
isMuted,
|
||||||
setMuted,
|
setMuted,
|
||||||
masterVolume,
|
masterVolume,
|
||||||
setMasterVolume,
|
setMasterVolume: setMasterVolumeSafe,
|
||||||
trackLevels,
|
trackLevels,
|
||||||
setTrackLevel,
|
setTrackLevel,
|
||||||
trackKeys: TRACK_KEYS,
|
trackKeys: TRACK_KEYS,
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ const isQuickAvailablePreset = (preset: SoundPreset | undefined): preset is Soun
|
|||||||
return !PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
return !PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getQuickSoundPresets = (presets: SoundPreset[], selectedPresetId: string) => {
|
export const getQuickSoundPresets = (presets: SoundPreset[]) => {
|
||||||
const uniquePresetIds = Array.from(new Set([selectedPresetId, ...QUICK_SOUND_FALLBACK_IDS]));
|
return QUICK_SOUND_FALLBACK_IDS
|
||||||
|
|
||||||
return uniquePresetIds
|
|
||||||
.map((presetId) => presets.find((preset) => preset.id === presetId))
|
.map((presetId) => presets.find((preset) => preset.id === presetId))
|
||||||
.filter(isQuickAvailablePreset)
|
.filter(isQuickAvailablePreset)
|
||||||
.slice(0, 3);
|
.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';
|
'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 { PlanTier } from '@/entities/plan';
|
||||||
import type { RoomTheme } from '@/entities/room';
|
import type { RoomTheme } from '@/entities/room';
|
||||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
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 type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
|
||||||
import { applyQuickPack } from '../model/applyQuickPack';
|
import { applyQuickPack } from '../model/applyQuickPack';
|
||||||
import { getQuickSoundPresets } from '../model/getQuickSoundPresets';
|
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';
|
import { InboxToolPanel } from './panels/InboxToolPanel';
|
||||||
interface SpaceToolsDockWidgetProps {
|
interface SpaceToolsDockWidgetProps {
|
||||||
isFocusMode: boolean;
|
isFocusMode: boolean;
|
||||||
@@ -22,6 +25,10 @@ interface SpaceToolsDockWidgetProps {
|
|||||||
selectedTimerLabel: string;
|
selectedTimerLabel: string;
|
||||||
timerPresets: TimerPreset[];
|
timerPresets: TimerPreset[];
|
||||||
selectedPresetId: string;
|
selectedPresetId: string;
|
||||||
|
soundVolume: number;
|
||||||
|
onSetSoundVolume: (volume: number) => void;
|
||||||
|
isSoundMuted: boolean;
|
||||||
|
onSetSoundMuted: (nextMuted: boolean) => void;
|
||||||
thoughts: RecentThought[];
|
thoughts: RecentThought[];
|
||||||
thoughtCount: number;
|
thoughtCount: number;
|
||||||
onRoomSelect: (roomId: string) => void;
|
onRoomSelect: (roomId: string) => void;
|
||||||
@@ -43,6 +50,10 @@ export const SpaceToolsDockWidget = ({
|
|||||||
selectedTimerLabel,
|
selectedTimerLabel,
|
||||||
timerPresets,
|
timerPresets,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
|
soundVolume,
|
||||||
|
onSetSoundVolume,
|
||||||
|
isSoundMuted,
|
||||||
|
onSetSoundMuted,
|
||||||
thoughts,
|
thoughts,
|
||||||
thoughtCount,
|
thoughtCount,
|
||||||
onRoomSelect,
|
onRoomSelect,
|
||||||
@@ -60,8 +71,10 @@ export const SpaceToolsDockWidget = ({
|
|||||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
||||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
|
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
|
||||||
const [plan, setPlan] = useState<PlanTier>('normal');
|
const [plan, setPlan] = useState<PlanTier>('normal');
|
||||||
const [isIdle, setIdle] = useState(false);
|
const [isIdle, setIdle] = useState(false);
|
||||||
|
const volumeFeedbackTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const selectedSoundLabel = useMemo(() => {
|
const selectedSoundLabel = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -70,8 +83,17 @@ export const SpaceToolsDockWidget = ({
|
|||||||
}, [selectedPresetId]);
|
}, [selectedPresetId]);
|
||||||
|
|
||||||
const quickSoundPresets = useMemo(() => {
|
const quickSoundPresets = useMemo(() => {
|
||||||
return getQuickSoundPresets(SOUND_PRESETS, selectedPresetId);
|
return getQuickSoundPresets(SOUND_PRESETS);
|
||||||
}, [selectedPresetId]);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (volumeFeedbackTimerRef.current) {
|
||||||
|
window.clearTimeout(volumeFeedbackTimerRef.current);
|
||||||
|
volumeFeedbackTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openPopover) {
|
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 handleInboxComplete = (thought: RecentThought) => {
|
||||||
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
||||||
|
|
||||||
@@ -262,6 +275,42 @@ export const SpaceToolsDockWidget = ({
|
|||||||
const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
|
const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
|
||||||
applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{openPopover ? (
|
{openPopover ? (
|
||||||
@@ -287,40 +336,12 @@ export const SpaceToolsDockWidget = ({
|
|||||||
|
|
||||||
{isFocusMode ? (
|
{isFocusMode ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<FocusRightRail
|
||||||
className={cn(
|
isIdle={isIdle}
|
||||||
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
|
thoughtCount={thoughtCount}
|
||||||
isIdle ? 'opacity-34' : 'opacity-78',
|
onOpenInbox={() => openUtilityPanel('inbox')}
|
||||||
)}
|
onOpenControlCenter={() => openUtilityPanel('control-center')}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -344,29 +365,12 @@ export const SpaceToolsDockWidget = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{openPopover === 'notes' ? (
|
{openPopover === 'notes' ? (
|
||||||
<div
|
<QuickNotesPopover
|
||||||
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"
|
noteDraft={noteDraft}
|
||||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
|
onDraftChange={setNoteDraft}
|
||||||
>
|
onDraftEnter={handleNoteSubmit}
|
||||||
<p className="text-[11px] text-white/56">떠오른 생각을 잠깐 주차해요</p>
|
onSubmit={handleNoteSubmit}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,37 +397,26 @@ export const SpaceToolsDockWidget = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{openPopover === 'sound' ? (
|
{openPopover === 'sound' ? (
|
||||||
<div
|
<QuickSoundPopover
|
||||||
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"
|
selectedSoundLabel={selectedSoundLabel}
|
||||||
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
|
isSoundMuted={isSoundMuted}
|
||||||
>
|
soundVolume={soundVolume}
|
||||||
<p className="text-[11px] text-white/56">빠른 사운드 전환</p>
|
volumeFeedback={volumeFeedback}
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
quickSoundPresets={quickSoundPresets}
|
||||||
{quickSoundPresets.map((preset) => {
|
selectedPresetId={selectedPresetId}
|
||||||
const selected = preset.id === selectedPresetId;
|
onToggleMute={() => {
|
||||||
|
const nextMuted = !isSoundMuted;
|
||||||
return (
|
onSetSoundMuted(nextMuted);
|
||||||
<button
|
showVolumeFeedback(nextMuted ? 0 : soundVolume);
|
||||||
key={preset.id}
|
}}
|
||||||
type="button"
|
onVolumeChange={handleVolumeChange}
|
||||||
onClick={() => {
|
onVolumeKeyDown={handleVolumeKeyDown}
|
||||||
onSelectPreset(preset.id);
|
onSelectPreset={(presetId) => {
|
||||||
pushToast({ title: `${preset.label}로 전환했어요.` });
|
onSelectPreset(presetId);
|
||||||
setOpenPopover(null);
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
const {
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
setSelectedPresetId,
|
setSelectedPresetId,
|
||||||
|
masterVolume,
|
||||||
|
setMasterVolume,
|
||||||
|
isMuted,
|
||||||
|
setMuted,
|
||||||
} = useSoundPresetSelection(initialSoundPresetId);
|
} = useSoundPresetSelection(initialSoundPresetId);
|
||||||
|
|
||||||
const selectedRoom = useMemo(() => {
|
const selectedRoom = useMemo(() => {
|
||||||
@@ -212,6 +216,10 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
onRoomSelect={setSelectedRoomId}
|
onRoomSelect={setSelectedRoomId}
|
||||||
onTimerSelect={setSelectedTimerLabel}
|
onTimerSelect={setSelectedTimerLabel}
|
||||||
onSelectPreset={setSelectedPresetId}
|
onSelectPreset={setSelectedPresetId}
|
||||||
|
soundVolume={masterVolume}
|
||||||
|
onSetSoundVolume={setMasterVolume}
|
||||||
|
isSoundMuted={isMuted}
|
||||||
|
onSetSoundMuted={setMuted}
|
||||||
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
|
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
|
||||||
onDeleteThought={removeThought}
|
onDeleteThought={removeThought}
|
||||||
onSetThoughtCompleted={setThoughtCompleted}
|
onSetThoughtCompleted={setThoughtCompleted}
|
||||||
|
|||||||
Reference in New Issue
Block a user