refactor: FSD 구조 강화 및 파일 500줄 제한에 따른 대규모 리팩토링
- SpaceWorkspaceWidget 로직을 전용 훅 및 유틸리티로 분리 (900줄 -> 300줄) - useSpaceWorkspaceSelection 훅을 기능별(영속성, 진단 등) 소형 훅으로 분리 - SpaceToolsDockWidget의 상태 및 핸들러 로직 추출 - 거대 i18n 번역 파일(ko.ts)을 도메인별 메시지 파일로 구조화 - AdminConsoleWidget 누락분 추가 및 미디어 엔티티 타입 오류 수정
This commit is contained in:
206
src/widgets/space-tools-dock/model/useSpaceToolsDockHandlers.ts
Normal file
206
src/widgets/space-tools-dock/model/useSpaceToolsDockHandlers.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
143
src/widgets/space-tools-dock/model/useSpaceToolsDockState.ts
Normal file
143
src/widgets/space-tools-dock/model/useSpaceToolsDockState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
160
src/widgets/space-tools-dock/ui/FocusModeAnchors.tsx
Normal file
160
src/widgets/space-tools-dock/ui/FocusModeAnchors.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user