refactor: FSD 구조 강화 및 파일 500줄 제한에 따른 대규모 리팩토링

- SpaceWorkspaceWidget 로직을 전용 훅 및 유틸리티로 분리 (900줄 -> 300줄)
- useSpaceWorkspaceSelection 훅을 기능별(영속성, 진단 등) 소형 훅으로 분리
- SpaceToolsDockWidget의 상태 및 핸들러 로직 추출
- 거대 i18n 번역 파일(ko.ts)을 도메인별 메시지 파일로 구조화
- AdminConsoleWidget 누락분 추가 및 미디어 엔티티 타입 오류 수정
This commit is contained in:
2026-03-11 15:08:36 +09:00
parent 7867bd39ca
commit 35f1dfb92d
36 changed files with 3238 additions and 2611 deletions

View File

@@ -0,0 +1,206 @@
'use client';
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
import type { PlanTier } from '@/entities/plan';
interface UseSpaceToolsDockHandlersParams {
setIdle: (idle: boolean) => void;
setOpenPopover: (popover: SpaceAnchorPopoverId | null) => void;
setUtilityPanel: (panel: SpaceUtilityPanelId | null) => void;
onCaptureThought: (note: string) => RecentThought | null;
onDeleteThought: (thoughtId: string) => RecentThought | null;
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
onRestoreThought: (thought: RecentThought) => void;
onRestoreThoughts: (thoughts: RecentThought[]) => void;
onClearInbox: () => RecentThought[];
onStatusMessage: (payload: HudStatusLinePayload) => void;
onSetSoundVolume: (volume: number) => void;
onSetSoundMuted: (muted: boolean) => void;
soundVolume: number;
isSoundMuted: boolean;
showVolumeFeedback: (volume: number) => void;
}
export const useSpaceToolsDockHandlers = ({
setIdle,
setOpenPopover,
setUtilityPanel,
onCaptureThought,
onDeleteThought,
onSetThoughtCompleted,
onRestoreThought,
onRestoreThoughts,
onClearInbox,
onStatusMessage,
onSetSoundVolume,
onSetSoundMuted,
soundVolume,
isSoundMuted,
showVolumeFeedback,
}: UseSpaceToolsDockHandlersParams) => {
const { toolsDock } = copy.space;
const [noteDraft, setNoteDraft] = useState('');
const [plan, setPlan] = useState<PlanTier>('normal');
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
setIdle(false);
setOpenPopover(null);
setUtilityPanel(panel);
}, [setIdle, setOpenPopover, setUtilityPanel]);
const handleNoteSubmit = useCallback(() => {
const trimmedNote = noteDraft.trim();
if (!trimmedNote) {
return;
}
const addedThought = onCaptureThought(trimmedNote);
if (!addedThought) {
return;
}
setNoteDraft('');
onStatusMessage({
message: toolsDock.inboxSaved,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
const removed = onDeleteThought(addedThought.id);
if (!removed) {
return;
}
onStatusMessage({ message: toolsDock.inboxSaveUndone });
},
},
});
}, [noteDraft, onCaptureThought, onDeleteThought, onStatusMessage, toolsDock.inboxSaved, toolsDock.inboxSaveUndone, toolsDock.undo]);
const handleInboxComplete = useCallback((thought: RecentThought) => {
onSetThoughtCompleted(thought.id, !thought.isCompleted);
}, [onSetThoughtCompleted]);
const handleInboxDelete = useCallback((thought: RecentThought) => {
const removedThought = onDeleteThought(thought.id);
if (!removedThought) {
return;
}
onStatusMessage({
message: toolsDock.deleted,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
onRestoreThought(removedThought);
onStatusMessage({ message: toolsDock.deleteUndone });
},
},
});
}, [onDeleteThought, onRestoreThought, onStatusMessage, toolsDock.deleted, toolsDock.deleteUndone, toolsDock.undo]);
const handleInboxClear = useCallback(() => {
const snapshot = onClearInbox();
if (snapshot.length === 0) {
onStatusMessage({ message: toolsDock.emptyToClear });
return;
}
onStatusMessage({
message: toolsDock.clearedAll,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
onRestoreThoughts(snapshot);
onStatusMessage({ message: toolsDock.restored });
},
},
});
}, [onClearInbox, onRestoreThoughts, onStatusMessage, toolsDock.clearedAll, toolsDock.emptyToClear, toolsDock.restored, toolsDock.undo]);
const handlePlanPillClick = useCallback(() => {
if (plan === 'pro') {
openUtilityPanel('manage-plan');
return;
}
onStatusMessage({ message: toolsDock.normalPlanInfo });
}, [openUtilityPanel, onStatusMessage, plan, toolsDock.normalPlanInfo]);
const handleLockedClick = useCallback((source: string) => {
onStatusMessage({ message: toolsDock.proFeatureLocked(source) });
openUtilityPanel('paywall');
}, [onStatusMessage, openUtilityPanel, toolsDock]);
const handleSelectProFeature = useCallback((featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
}, [onStatusMessage, toolsDock.featureLabels]);
const handleStartPro = useCallback(() => {
setPlan('pro');
onStatusMessage({ message: toolsDock.purchaseMock });
openUtilityPanel('control-center');
}, [onStatusMessage, openUtilityPanel, toolsDock.purchaseMock]);
const handleVolumeChange = useCallback((nextVolume: number) => {
const clamped = Math.min(100, Math.max(0, nextVolume));
onSetSoundVolume(clamped);
if (isSoundMuted && clamped > 0) {
onSetSoundMuted(false);
}
showVolumeFeedback(clamped);
}, [isSoundMuted, onSetSoundMuted, onSetSoundVolume, showVolumeFeedback]);
const handleVolumeKeyDown = useCallback((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);
}, [handleVolumeChange, soundVolume]);
return {
noteDraft,
setNoteDraft,
plan,
setPlan,
openUtilityPanel,
handleNoteSubmit,
handleInboxComplete,
handleInboxDelete,
handleInboxClear,
handlePlanPillClick,
handleLockedClick,
handleSelectProFeature,
handleStartPro,
handleVolumeChange,
handleVolumeKeyDown,
};
};

View File

@@ -0,0 +1,143 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
interface UseSpaceToolsDockStateParams {
isFocusMode: boolean;
}
export const useSpaceToolsDockState = ({ isFocusMode }: UseSpaceToolsDockStateParams) => {
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [autoHideControls, setAutoHideControls] = useState(true);
const [isIdle, setIdle] = useState(false);
const [volumeFeedback, setVolumeFeedback] = useState<string | null>(null);
const volumeFeedbackTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (volumeFeedbackTimerRef.current) {
window.clearTimeout(volumeFeedbackTimerRef.current);
volumeFeedbackTimerRef.current = null;
}
};
}, []);
useEffect(() => {
if (isFocusMode) {
return;
}
const rafId = window.requestAnimationFrame(() => {
setOpenPopover(null);
setUtilityPanel(null);
setIdle(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [isFocusMode]);
useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) {
return;
}
let timerId: number | null = null;
const armIdleTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setIdle(true);
}, 3500);
};
const wake = () => {
setIdle(false);
armIdleTimer();
};
armIdleTimer();
window.addEventListener('pointermove', wake);
window.addEventListener('keydown', wake);
window.addEventListener('pointerdown', wake);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', wake);
window.removeEventListener('keydown', wake);
window.removeEventListener('pointerdown', wake);
};
}, [isFocusMode, openPopover, utilityPanel]);
useEffect(() => {
if (utilityPanel !== 'control-center' || !autoHideControls) {
return;
}
let timerId: number | null = null;
const closeDelayMs = 8000;
const armCloseTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setUtilityPanel((current) => (current === 'control-center' ? null : current));
}, closeDelayMs);
};
const resetTimer = () => {
armCloseTimer();
};
armCloseTimer();
window.addEventListener('pointermove', resetTimer);
window.addEventListener('pointerdown', resetTimer);
window.addEventListener('keydown', resetTimer);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', resetTimer);
window.removeEventListener('pointerdown', resetTimer);
window.removeEventListener('keydown', resetTimer);
};
}, [autoHideControls, utilityPanel]);
const showVolumeFeedback = (nextVolume: number) => {
setVolumeFeedback(`${nextVolume}%`);
if (volumeFeedbackTimerRef.current) {
window.clearTimeout(volumeFeedbackTimerRef.current);
}
volumeFeedbackTimerRef.current = window.setTimeout(() => {
setVolumeFeedback(null);
volumeFeedbackTimerRef.current = null;
}, 900);
};
return {
openPopover,
setOpenPopover,
utilityPanel,
setUtilityPanel,
autoHideControls,
setAutoHideControls,
isIdle,
setIdle,
volumeFeedback,
showVolumeFeedback,
};
};

View File

@@ -0,0 +1,160 @@
'use client';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SoundPreset } from '@/entities/session';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import type { SpaceAnchorPopoverId } from '../model/types';
import { ANCHOR_ICON, formatThoughtCount } from './constants';
import { FocusRightRail } from './FocusRightRail';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
interface FocusModeAnchorsProps {
isFocusMode: boolean;
isIdle: boolean;
openPopover: SpaceAnchorPopoverId | null;
thoughtCount: number;
noteDraft: string;
selectedSoundLabel: string;
isSoundMuted: boolean;
soundVolume: number;
volumeFeedback: string | null;
quickSoundPresets: SoundPreset[];
selectedPresetId: string;
onClosePopover: () => void;
onOpenInbox: () => void;
onOpenControlCenter: () => void;
onToggleNotes: () => void;
onToggleSound: () => void;
onNoteDraftChange: (value: string) => void;
onNoteSubmit: () => void;
onToggleMute: () => void;
onVolumeChange: (nextVolume: number) => void;
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
onSelectPreset: (presetId: string) => void;
}
const anchorContainerClassName =
'fixed z-30 transition-opacity bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]';
const anchorHaloClassName =
'pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]';
const anchorButtonClassName =
'inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100';
export const FocusModeAnchors = ({
isFocusMode,
isIdle,
openPopover,
thoughtCount,
noteDraft,
selectedSoundLabel,
isSoundMuted,
soundVolume,
volumeFeedback,
quickSoundPresets,
selectedPresetId,
onClosePopover,
onOpenInbox,
onOpenControlCenter,
onToggleNotes,
onToggleSound,
onNoteDraftChange,
onNoteSubmit,
onToggleMute,
onVolumeChange,
onVolumeKeyDown,
onSelectPreset,
}: FocusModeAnchorsProps) => {
if (!isFocusMode) {
return null;
}
return (
<>
{openPopover ? (
<button
type="button"
aria-label={copy.space.toolsDock.popoverCloseAria}
onClick={onClosePopover}
className="fixed inset-0 z-30"
/>
) : null}
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
/>
<div
className={cn(
anchorContainerClassName,
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleNotes}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
anchorContainerClassName,
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleSound}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
) : null}
</div>
</div>
</>
);
};

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useEffect, useMemo } 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';
import { copy } from '@/shared/i18n';
@@ -12,13 +11,13 @@ import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { cn } from '@/shared/lib/cn';
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
import { getQuickSoundPresets } from '../model/getQuickSoundPresets';
import { ANCHOR_ICON, formatThoughtCount, UTILITY_PANEL_TITLE } from './constants';
import { FocusRightRail } from './FocusRightRail';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
import { UTILITY_PANEL_TITLE } from './constants';
import { FocusModeAnchors } from './FocusModeAnchors';
import { InboxToolPanel } from './panels/InboxToolPanel';
import { useSpaceToolsDockState } from '../model/useSpaceToolsDockState';
import { useSpaceToolsDockHandlers } from '../model/useSpaceToolsDockHandlers';
interface SpaceToolsDockWidgetProps {
isFocusMode: boolean;
scenes: SceneTheme[];
@@ -76,15 +75,53 @@ export const SpaceToolsDockWidget = ({
onStatusMessage,
onExitRequested,
}: SpaceToolsDockWidgetProps) => {
const { toolsDock, controlCenter } = copy.space;
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [autoHideControls, setAutoHideControls] = useState(true);
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 { controlCenter } = copy.space;
const {
openPopover,
setOpenPopover,
utilityPanel,
setUtilityPanel,
autoHideControls,
setAutoHideControls,
isIdle,
setIdle,
volumeFeedback,
showVolumeFeedback,
} = useSpaceToolsDockState({ isFocusMode });
const {
noteDraft,
setNoteDraft,
plan,
openUtilityPanel,
handleNoteSubmit,
handleInboxComplete,
handleInboxDelete,
handleInboxClear,
handlePlanPillClick,
handleLockedClick,
handleSelectProFeature,
handleStartPro,
handleVolumeChange,
handleVolumeKeyDown,
} = useSpaceToolsDockHandlers({
setIdle,
setOpenPopover,
setUtilityPanel,
onCaptureThought,
onDeleteThought,
onSetThoughtCompleted,
onRestoreThought,
onRestoreThoughts,
onClearInbox,
onStatusMessage,
onSetSoundVolume,
onSetSoundMuted,
soundVolume,
isSoundMuted,
showVolumeFeedback,
});
const selectedSoundLabel = useMemo(() => {
return (
@@ -96,15 +133,6 @@ export const SpaceToolsDockWidget = ({
return getQuickSoundPresets(SOUND_PRESETS);
}, []);
useEffect(() => {
return () => {
if (volumeFeedbackTimerRef.current) {
window.clearTimeout(volumeFeedbackTimerRef.current);
volumeFeedbackTimerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!openPopover) {
return;
@@ -121,263 +149,10 @@ export const SpaceToolsDockWidget = ({
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [openPopover]);
useEffect(() => {
if (isFocusMode) {
return;
}
const rafId = window.requestAnimationFrame(() => {
setOpenPopover(null);
setUtilityPanel(null);
setIdle(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [isFocusMode]);
useEffect(() => {
if (!isFocusMode || openPopover || utilityPanel) {
return;
}
let timerId: number | null = null;
const armIdleTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setIdle(true);
}, 3500);
};
const wake = () => {
setIdle(false);
armIdleTimer();
};
armIdleTimer();
window.addEventListener('pointermove', wake);
window.addEventListener('keydown', wake);
window.addEventListener('pointerdown', wake);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', wake);
window.removeEventListener('keydown', wake);
window.removeEventListener('pointerdown', wake);
};
}, [isFocusMode, openPopover, utilityPanel]);
useEffect(() => {
if (utilityPanel !== 'control-center' || !autoHideControls) {
return;
}
let timerId: number | null = null;
const closeDelayMs = 8000;
const armCloseTimer = () => {
if (timerId) {
window.clearTimeout(timerId);
}
timerId = window.setTimeout(() => {
setUtilityPanel((current) => (current === 'control-center' ? null : current));
}, closeDelayMs);
};
const resetTimer = () => {
armCloseTimer();
};
armCloseTimer();
window.addEventListener('pointermove', resetTimer);
window.addEventListener('pointerdown', resetTimer);
window.addEventListener('keydown', resetTimer);
return () => {
if (timerId) {
window.clearTimeout(timerId);
}
window.removeEventListener('pointermove', resetTimer);
window.removeEventListener('pointerdown', resetTimer);
window.removeEventListener('keydown', resetTimer);
};
}, [autoHideControls, utilityPanel]);
const openUtilityPanel = (panel: SpaceUtilityPanelId) => {
setIdle(false);
setOpenPopover(null);
setUtilityPanel(panel);
};
const handleNoteSubmit = () => {
const trimmedNote = noteDraft.trim();
if (!trimmedNote) {
return;
}
const addedThought = onCaptureThought(trimmedNote);
if (!addedThought) {
return;
}
setNoteDraft('');
onStatusMessage({
message: toolsDock.inboxSaved,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
const removed = onDeleteThought(addedThought.id);
if (!removed) {
return;
}
onStatusMessage({ message: toolsDock.inboxSaveUndone });
},
},
});
};
const handleInboxComplete = (thought: RecentThought) => {
onSetThoughtCompleted(thought.id, !thought.isCompleted);
};
const handleInboxDelete = (thought: RecentThought) => {
const removedThought = onDeleteThought(thought.id);
if (!removedThought) {
return;
}
onStatusMessage({
message: toolsDock.deleted,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
onRestoreThought(removedThought);
onStatusMessage({ message: toolsDock.deleteUndone });
},
},
});
};
const handleInboxClear = () => {
const snapshot = onClearInbox();
if (snapshot.length === 0) {
onStatusMessage({ message: toolsDock.emptyToClear });
return;
}
onStatusMessage({
message: toolsDock.clearedAll,
durationMs: 4200,
priority: 'undo',
action: {
label: toolsDock.undo,
onClick: () => {
onRestoreThoughts(snapshot);
onStatusMessage({ message: toolsDock.restored });
},
},
});
};
const handlePlanPillClick = () => {
if (plan === 'pro') {
openUtilityPanel('manage-plan');
return;
}
onStatusMessage({ message: toolsDock.normalPlanInfo });
};
const handleLockedClick = (source: string) => {
onStatusMessage({ message: toolsDock.proFeatureLocked(source) });
openUtilityPanel('paywall');
};
const handleSelectProFeature = (featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
};
const handleStartPro = () => {
setPlan('pro');
onStatusMessage({ message: toolsDock.purchaseMock });
openUtilityPanel('control-center');
};
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);
};
}, [openPopover, setOpenPopover]);
return (
<>
{isFocusMode && openPopover ? (
<button
type="button"
aria-label={toolsDock.popoverCloseAria}
onClick={() => setOpenPopover(null)}
className="fixed inset-0 z-30"
/>
) : null}
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
@@ -390,99 +165,43 @@ export const SpaceToolsDockWidget = ({
/>
</div>
{isFocusMode ? (
<>
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
onOpenInbox={() => openUtilityPanel('inbox')}
onOpenControlCenter={() => openUtilityPanel('control-center')}
/>
<div
className={cn(
'fixed z-30 transition-opacity left-[calc(env(safe-area-inset-left,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={setNoteDraft}
onDraftEnter={handleNoteSubmit}
onSubmit={handleNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div
aria-hidden
className="pointer-events-none absolute -inset-x-5 -inset-y-4 -z-10 rounded-[999px] bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.11)_48%,rgba(2,6,23,0)_78%)]"
/>
<button
type="button"
onClick={() => {
setIdle(false);
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
}}
className="inline-flex items-center gap-1.5 rounded-full border border-white/14 bg-black/24 px-2.5 py-1.5 text-[11px] text-white/88 backdrop-blur-md transition-opacity hover:opacity-100"
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<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) => {
onQuickSoundSelect(presetId);
setOpenPopover(null);
}}
/>
) : null}
</div>
</div>
</>
) : null}
<FocusModeAnchors
isFocusMode={isFocusMode}
isIdle={isIdle}
openPopover={openPopover}
thoughtCount={thoughtCount}
noteDraft={noteDraft}
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onClosePopover={() => setOpenPopover(null)}
onOpenInbox={() => openUtilityPanel('inbox')}
onOpenControlCenter={() => openUtilityPanel('control-center')}
onToggleNotes={() => {
setIdle(false);
setOpenPopover((current) => (current === 'notes' ? null : 'notes'));
}}
onToggleSound={() => {
setIdle(false);
setOpenPopover((current) => (current === 'sound' ? null : 'sound'));
}}
onNoteDraftChange={setNoteDraft}
onNoteSubmit={handleNoteSubmit}
onToggleMute={() => {
const nextMuted = !isSoundMuted;
onSetSoundMuted(nextMuted);
showVolumeFeedback(nextMuted ? 0 : soundVolume);
}}
onVolumeChange={handleVolumeChange}
onVolumeKeyDown={handleVolumeKeyDown}
onSelectPreset={(presetId) => {
onQuickSoundSelect(presetId);
setOpenPopover(null);
}}
/>
<SpaceSideSheet
open={isFocusMode && utilityPanel !== null}
@@ -541,8 +260,8 @@ export const SpaceToolsDockWidget = ({
{utilityPanel === 'manage-plan' ? (
<ManagePlanSheetContent
onClose={() => setUtilityPanel(null)}
onManage={() => onStatusMessage({ message: toolsDock.manageSubscriptionMock })}
onRestore={() => onStatusMessage({ message: toolsDock.restorePurchaseMock })}
onManage={() => onStatusMessage({ message: copy.space.toolsDock.manageSubscriptionMock })}
onRestore={() => onStatusMessage({ message: copy.space.toolsDock.restorePurchaseMock })}
/>
) : null}
</SpaceSideSheet>