From 3cddd3c1f42a1d0a8c4a4f84214152fed976c7be Mon Sep 17 00:00:00 2001 From: corpi Date: Wed, 4 Mar 2026 14:36:38 +0900 Subject: [PATCH] =?UTF-8?q?refactor(control-center):=20Quick=20Controls=20?= =?UTF-8?q?=EC=9E=AC=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B0=8F=20=ED=94=8C?= =?UTF-8?q?=EB=9E=9C/=EC=9E=A0=EA=B8=88=20=EA=B2=B0=EC=A0=9C=20=EB=8F=99?= =?UTF-8?q?=EC=84=A0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/plan/index.ts | 2 + src/entities/plan/model/mockPlan.ts | 23 ++ src/entities/plan/model/types.ts | 7 + src/entities/session/model/types.ts | 1 + src/entities/session/model/useThoughtInbox.ts | 92 ++++- src/features/inbox/ui/InboxList.tsx | 36 +- src/features/paywall-sheet/index.ts | 2 + .../ui/ManagePlanSheetContent.tsx | 47 +++ .../paywall-sheet/ui/PaywallSheetContent.tsx | 86 +++++ src/features/plan-pill/index.ts | 1 + src/features/plan-pill/ui/PlanPill.tsx | 26 ++ src/shared/ui/Toast.tsx | 32 +- src/widgets/control-center-sheet/index.ts | 1 + .../ui/ControlCenterSheetWidget.tsx | 237 +++++++++++++ .../space-sheet-shell/ui/SpaceSideSheet.tsx | 25 +- .../space-tools-dock/model/applyQuickPack.ts | 31 ++ src/widgets/space-tools-dock/model/types.ts | 2 +- .../ui/SpaceToolsDockWidget.tsx | 331 ++++++++++++------ src/widgets/space-tools-dock/ui/constants.tsx | 85 +++++ .../ui/panels/InboxToolPanel.tsx | 50 ++- .../ui/SpaceWorkspaceWidget.tsx | 15 +- 21 files changed, 983 insertions(+), 149 deletions(-) create mode 100644 src/entities/plan/index.ts create mode 100644 src/entities/plan/model/mockPlan.ts create mode 100644 src/entities/plan/model/types.ts create mode 100644 src/features/paywall-sheet/index.ts create mode 100644 src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx create mode 100644 src/features/paywall-sheet/ui/PaywallSheetContent.tsx create mode 100644 src/features/plan-pill/index.ts create mode 100644 src/features/plan-pill/ui/PlanPill.tsx create mode 100644 src/widgets/control-center-sheet/index.ts create mode 100644 src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx create mode 100644 src/widgets/space-tools-dock/model/applyQuickPack.ts create mode 100644 src/widgets/space-tools-dock/ui/constants.tsx diff --git a/src/entities/plan/index.ts b/src/entities/plan/index.ts new file mode 100644 index 0000000..f39caef --- /dev/null +++ b/src/entities/plan/index.ts @@ -0,0 +1,2 @@ +export * from './model/mockPlan'; +export * from './model/types'; diff --git a/src/entities/plan/model/mockPlan.ts b/src/entities/plan/model/mockPlan.ts new file mode 100644 index 0000000..4b350b7 --- /dev/null +++ b/src/entities/plan/model/mockPlan.ts @@ -0,0 +1,23 @@ +import type { PlanLockedPack } from './types'; + +export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain']; +export const PRO_LOCKED_TIMER_LABELS = ['90/20']; +export const PRO_LOCKED_SOUND_IDS = ['cafe-work', 'fireplace']; + +export const PRO_PRESET_PACKS: PlanLockedPack[] = [ + { + id: 'deep-work', + name: 'Deep Work', + description: '긴 몰입 세션을 위한 무드 묶음', + }, + { + id: 'gentle', + name: 'Gentle', + description: '저자극 휴식 중심 프리셋', + }, + { + id: 'cafe', + name: 'Cafe', + description: '카페톤 배경과 사운드 조합', + }, +]; diff --git a/src/entities/plan/model/types.ts b/src/entities/plan/model/types.ts new file mode 100644 index 0000000..3540455 --- /dev/null +++ b/src/entities/plan/model/types.ts @@ -0,0 +1,7 @@ +export type PlanTier = 'normal' | 'pro'; + +export interface PlanLockedPack { + id: string; + name: string; + description: string; +} diff --git a/src/entities/session/model/types.ts b/src/entities/session/model/types.ts index e405067..16de486 100644 --- a/src/entities/session/model/types.ts +++ b/src/entities/session/model/types.ts @@ -38,4 +38,5 @@ export interface RecentThought { text: string; roomName: string; capturedAt: string; + isCompleted?: boolean; } diff --git a/src/entities/session/model/useThoughtInbox.ts b/src/entities/session/model/useThoughtInbox.ts index 30f81b8..e3090e1 100644 --- a/src/entities/session/model/useThoughtInbox.ts +++ b/src/entities/session/model/useThoughtInbox.ts @@ -30,7 +30,8 @@ const readStoredThoughts = () => { typeof thought.id === 'string' && typeof thought.text === 'string' && typeof thought.roomName === 'string' && - typeof thought.capturedAt === 'string' + typeof thought.capturedAt === 'string' && + (typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean') ); }); } catch { @@ -73,26 +74,89 @@ export const useThoughtInbox = () => { const trimmedText = text.trim(); if (!trimmedText) { - return; + return null; } + const thought: RecentThought = { + id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + text: trimmedText, + roomName, + capturedAt: '방금 전', + isCompleted: false, + }; + setThoughts((current) => { - const next: RecentThought[] = [ - { - id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, - text: trimmedText, - roomName, - capturedAt: '방금 전', - }, - ...current, - ].slice(0, MAX_THOUGHT_INBOX_ITEMS); + const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS); return next; }); + + return thought; + }, []); + + const removeThought = useCallback((thoughtId: string) => { + let removedThought: RecentThought | null = null; + + setThoughts((current) => { + const target = current.find((thought) => thought.id === thoughtId); + + if (!target) { + return current; + } + + removedThought = target; + + return current.filter((thought) => thought.id !== thoughtId); + }); + + return removedThought; }, []); const clearThoughts = useCallback(() => { - setThoughts([]); + let snapshot: RecentThought[] = []; + + setThoughts((current) => { + snapshot = current; + return []; + }); + + return snapshot; + }, []); + + const restoreThought = useCallback((thought: RecentThought) => { + setThoughts((current) => { + const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id); + return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS); + }); + }, []); + + const restoreThoughts = useCallback((snapshot: RecentThought[]) => { + setThoughts(() => { + return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS); + }); + }, []); + + const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => { + let previousThought: RecentThought | null = null; + + setThoughts((current) => { + const targetIndex = current.findIndex((thought) => thought.id === thoughtId); + + if (targetIndex < 0) { + return current; + } + + previousThought = current[targetIndex]; + const next = [...current]; + next[targetIndex] = { + ...current[targetIndex], + isCompleted, + }; + + return next; + }); + + return previousThought; }, []); const recentThoughts = useMemo(() => thoughts.slice(0, 3), [thoughts]); @@ -102,6 +166,10 @@ export const useThoughtInbox = () => { recentThoughts, thoughtCount: thoughts.length, addThought, + removeThought, clearThoughts, + restoreThought, + restoreThoughts, + setThoughtCompleted, }; }; diff --git a/src/features/inbox/ui/InboxList.tsx b/src/features/inbox/ui/InboxList.tsx index e5c67cd..38458f4 100644 --- a/src/features/inbox/ui/InboxList.tsx +++ b/src/features/inbox/ui/InboxList.tsx @@ -3,10 +3,12 @@ import { cn } from '@/shared/lib/cn'; interface InboxListProps { thoughts: RecentThought[]; + onCompleteThought: (thought: RecentThought) => void; + onDeleteThought: (thought: RecentThought) => void; className?: string; } -export const InboxList = ({ thoughts, className }: InboxListProps) => { +export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, className }: InboxListProps) => { if (thoughts.length === 0) { return (

{ {thoughts.slice(0, 10).map((thought) => (

  • -

    {thought.text}

    +

    + {thought.text} +

    {thought.roomName} · {thought.capturedAt}

    +
    + + +
  • ))} diff --git a/src/features/paywall-sheet/index.ts b/src/features/paywall-sheet/index.ts new file mode 100644 index 0000000..53dd2ab --- /dev/null +++ b/src/features/paywall-sheet/index.ts @@ -0,0 +1,2 @@ +export * from './ui/ManagePlanSheetContent'; +export * from './ui/PaywallSheetContent'; diff --git a/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx b/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx new file mode 100644 index 0000000..25e846a --- /dev/null +++ b/src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx @@ -0,0 +1,47 @@ +interface ManagePlanSheetContentProps { + onClose: () => void; + onManage: () => void; + onRestore: () => void; +} + +export const ManagePlanSheetContent = ({ + onClose, + onManage, + onRestore, +}: ManagePlanSheetContentProps) => { + return ( +
    +
    +

    PRO 관리

    +

    결제/복원은 더미 동작이며 실제 연동은 하지 않아요.

    +
    + +
    + + +
    + +
    + +
    +
    + ); +}; diff --git a/src/features/paywall-sheet/ui/PaywallSheetContent.tsx b/src/features/paywall-sheet/ui/PaywallSheetContent.tsx new file mode 100644 index 0000000..81927c3 --- /dev/null +++ b/src/features/paywall-sheet/ui/PaywallSheetContent.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useState } from 'react'; +import { cn } from '@/shared/lib/cn'; + +interface PaywallSheetContentProps { + onStartPro: () => void; + onClose: () => void; +} + +type BillingCycle = 'monthly' | 'yearly'; + +const BILLING_OPTIONS: Array<{ id: BillingCycle; label: string; caption: string }> = [ + { id: 'monthly', label: '월간', caption: '월 9,900원 (더미)' }, + { id: 'yearly', label: '연간', caption: '연 79,000원 (더미)' }, +]; + +const VALUE_POINTS = [ + '더 많은 공간 / 고화질 배경', + '작업용 BGM / 사운드 확장', + '프리셋 팩 / 고급 타이머', +]; + +export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => { + const [cycle, setCycle] = useState('monthly'); + + return ( +
    +
    +

    PRO로 더 깊게

    +

    필요할 때만 잠금 해제하고, 무대는 그대로 유지해요.

    +
    + +
      + {VALUE_POINTS.map((point) => ( +
    • + {point} +
    • + ))} +
    + +
    +

    가격

    +
    + {BILLING_OPTIONS.map((option) => { + const selected = option.id === cycle; + + return ( + + ); + })} +
    +
    + +
    + + +
    +
    + ); +}; diff --git a/src/features/plan-pill/index.ts b/src/features/plan-pill/index.ts new file mode 100644 index 0000000..e81475b --- /dev/null +++ b/src/features/plan-pill/index.ts @@ -0,0 +1 @@ +export * from './ui/PlanPill'; diff --git a/src/features/plan-pill/ui/PlanPill.tsx b/src/features/plan-pill/ui/PlanPill.tsx new file mode 100644 index 0000000..55e23ae --- /dev/null +++ b/src/features/plan-pill/ui/PlanPill.tsx @@ -0,0 +1,26 @@ +import type { PlanTier } from '@/entities/plan'; +import { cn } from '@/shared/lib/cn'; + +interface PlanPillProps { + plan: PlanTier; + onClick: () => void; +} + +export const PlanPill = ({ plan, onClick }: PlanPillProps) => { + const isPro = plan === 'pro'; + + return ( + + ); +}; diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx index de85d8b..3234c8e 100644 --- a/src/shared/ui/Toast.tsx +++ b/src/shared/ui/Toast.tsx @@ -7,6 +7,11 @@ import { cn } from '@/shared/lib/cn'; interface ToastPayload { title: string; description?: string; + durationMs?: number; + action?: { + label: string; + onClick: () => void; + }; } interface ToastItem extends ToastPayload { @@ -22,15 +27,20 @@ const ToastContext = createContext(null); export const ToastProvider = ({ children }: { children: ReactNode }) => { const [toasts, setToasts] = useState([]); + const removeToast = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + const pushToast = useCallback((payload: ToastPayload) => { const id = Date.now() + Math.floor(Math.random() * 10000); + const durationMs = payload.durationMs ?? 2400; setToasts((current) => [...current, { id, ...payload }]); window.setTimeout(() => { - setToasts((current) => current.filter((toast) => toast.id !== id)); - }, 2400); - }, []); + removeToast(id); + }, durationMs); + }, [removeToast]); const value = useMemo(() => ({ pushToast }), [pushToast]); @@ -42,7 +52,7 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => {
    @@ -50,6 +60,20 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => { {toast.description ? (

    {toast.description}

    ) : null} + {toast.action ? ( +
    + +
    + ) : null}
    ))} diff --git a/src/widgets/control-center-sheet/index.ts b/src/widgets/control-center-sheet/index.ts new file mode 100644 index 0000000..feb4811 --- /dev/null +++ b/src/widgets/control-center-sheet/index.ts @@ -0,0 +1 @@ +export * from './ui/ControlCenterSheetWidget'; diff --git a/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx new file mode 100644 index 0000000..8614c6d --- /dev/null +++ b/src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useMemo } from 'react'; +import type { PlanTier } from '@/entities/plan'; +import { + PRO_LOCKED_ROOM_IDS, + PRO_LOCKED_SOUND_IDS, + PRO_LOCKED_TIMER_LABELS, +} from '@/entities/plan'; +import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room'; +import type { SoundPreset, TimerPreset } from '@/entities/session'; +import { cn } from '@/shared/lib/cn'; + +interface ControlCenterSheetWidgetProps { + plan: PlanTier; + rooms: RoomTheme[]; + selectedRoomId: string; + selectedTimerLabel: string; + selectedSoundPresetId: string; + timerPresets: TimerPreset[]; + soundPresets: SoundPreset[]; + onSelectRoom: (roomId: string) => void; + onSelectTimer: (timerLabel: string) => void; + onSelectSound: (soundPresetId: string) => void; + onApplyPack: (packId: QuickPackId) => void; + onLockedClick: (source: string) => void; +} + +type QuickPackId = 'balanced' | 'deep-work' | 'gentle'; + +interface QuickPack { + id: QuickPackId; + name: string; + combo: string; + locked: boolean; +} + +const QUICK_PACKS: QuickPack[] = [ + { + id: 'balanced', + name: 'Balanced', + combo: '25/5 + Rain Focus', + locked: false, + }, + { + id: 'deep-work', + name: 'Deep Work', + combo: '50/10 + Deep White', + locked: true, + }, + { + id: 'gentle', + name: 'Gentle', + combo: '25/5 + Silent', + locked: true, + }, +]; + +const LockBadge = () => { + return ( + + LOCK PRO + + ); +}; + +const SectionTitle = ({ title, description }: { title: string; description: string }) => { + return ( +
    +

    {title}

    +

    {description}

    +
    + ); +}; + +export const ControlCenterSheetWidget = ({ + plan, + rooms, + selectedRoomId, + selectedTimerLabel, + selectedSoundPresetId, + timerPresets, + soundPresets, + onSelectRoom, + onSelectTimer, + onSelectSound, + onApplyPack, + onLockedClick, +}: ControlCenterSheetWidgetProps) => { + const isPro = plan === 'pro'; + + const selectedRoom = useMemo(() => { + return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0]; + }, [rooms, selectedRoomId]); + + const selectedSound = useMemo(() => { + return soundPresets.find((preset) => preset.id === selectedSoundPresetId) ?? soundPresets[0]; + }, [selectedSoundPresetId, soundPresets]); + + return ( +
    +
    + +
    + {rooms.slice(0, 6).map((room) => { + const selected = room.id === selectedRoomId; + const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id); + + return ( + + ); + })} +
    +
    + +
    + +
    + {timerPresets.slice(0, 3).map((preset) => { + const selected = preset.label === selectedTimerLabel; + const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label); + + return ( + + ); + })} +
    +
    + +
    + +
    + {soundPresets.slice(0, 6).map((preset) => { + const selected = preset.id === selectedSoundPresetId; + const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id); + + return ( + + ); + })} +
    +
    + +
    + +
    + {QUICK_PACKS.map((pack) => { + const locked = !isPro && pack.locked; + + return ( + + ); + })} +
    +
    +
    + ); +}; diff --git a/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx index 7c7977a..32d4d2c 100644 --- a/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx +++ b/src/widgets/space-sheet-shell/ui/SpaceSideSheet.tsx @@ -14,6 +14,7 @@ interface SpaceSideSheetProps { footer?: ReactNode; widthClassName?: string; dismissible?: boolean; + headerAction?: ReactNode; } export const SpaceSideSheet = ({ @@ -25,6 +26,7 @@ export const SpaceSideSheet = ({ footer, widthClassName, dismissible = true, + headerAction, }: SpaceSideSheetProps) => { const closeTimerRef = useRef(null); const [shouldRender, setShouldRender] = useState(open); @@ -123,16 +125,19 @@ export const SpaceSideSheet = ({

    {title}

    {subtitle ?

    {subtitle}

    : null} - {dismissible ? ( - - ) : null} +
    + {headerAction} + {dismissible ? ( + + ) : null} +
    {children}
    diff --git a/src/widgets/space-tools-dock/model/applyQuickPack.ts b/src/widgets/space-tools-dock/model/applyQuickPack.ts new file mode 100644 index 0000000..a7a2805 --- /dev/null +++ b/src/widgets/space-tools-dock/model/applyQuickPack.ts @@ -0,0 +1,31 @@ +interface ApplyQuickPackParams { + packId: 'balanced' | 'deep-work' | 'gentle'; + onTimerSelect: (timerLabel: string) => void; + onSelectPreset: (presetId: string) => void; + pushToast: (payload: { title: string; description?: string }) => void; +} + +export const applyQuickPack = ({ + packId, + onTimerSelect, + onSelectPreset, + pushToast, +}: ApplyQuickPackParams) => { + if (packId === 'balanced') { + onTimerSelect('25/5'); + onSelectPreset('rain-focus'); + pushToast({ title: 'Balanced 팩을 적용했어요.' }); + return; + } + + if (packId === 'deep-work') { + onTimerSelect('50/10'); + onSelectPreset('deep-white'); + pushToast({ title: 'Deep Work 팩을 적용했어요.' }); + return; + } + + onTimerSelect('25/5'); + onSelectPreset('silent'); + pushToast({ title: 'Gentle 팩을 적용했어요.' }); +}; diff --git a/src/widgets/space-tools-dock/model/types.ts b/src/widgets/space-tools-dock/model/types.ts index 4f0636d..1afbd32 100644 --- a/src/widgets/space-tools-dock/model/types.ts +++ b/src/widgets/space-tools-dock/model/types.ts @@ -1,2 +1,2 @@ export type SpaceAnchorPopoverId = 'sound' | 'notes'; -export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats'; +export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall'; diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx index be0df6e..958c165 100644 --- a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx +++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx @@ -1,17 +1,19 @@ 'use client'; - -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, 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'; import { ExitHoldButton } from '@/features/exit-hold'; +import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet'; +import { PlanPill } from '@/features/plan-pill'; import { useToast } from '@/shared/ui'; 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 { applyQuickPack } from '../model/applyQuickPack'; +import { ANCHOR_ICON, formatThoughtCount, RAIL_ICON, UTILITY_PANEL_TITLE } from './constants'; import { InboxToolPanel } from './panels/InboxToolPanel'; -import { SettingsToolPanel } from './panels/SettingsToolPanel'; -import { StatsToolPanel } from './panels/StatsToolPanel'; - interface SpaceToolsDockWidgetProps { isFocusMode: boolean; rooms: RoomTheme[]; @@ -24,61 +26,15 @@ interface SpaceToolsDockWidgetProps { onRoomSelect: (roomId: string) => void; onTimerSelect: (timerLabel: string) => void; onSelectPreset: (presetId: string) => void; - onCaptureThought: (note: string) => void; - onClearInbox: () => 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[]; onExitRequested: () => void; } -const ANCHOR_ICON = { - sound: ( - - - - - ), - notes: ( - - - - - - ), -}; - -const UTILITY_PANEL_TITLE: Record = { - inbox: '인박스', - stats: '집중 요약', - settings: '설정', -}; - -const formatThoughtCount = (count: number) => { - if (count < 1) { - return '0'; - } - - if (count > 9) { - return '9+'; - } - - return String(count); -}; - export const SpaceToolsDockWidget = ({ isFocusMode, rooms, @@ -92,6 +48,10 @@ export const SpaceToolsDockWidget = ({ onTimerSelect, onSelectPreset, onCaptureThought, + onDeleteThought, + onSetThoughtCompleted, + onRestoreThought, + onRestoreThoughts, onClearInbox, onExitRequested, }: SpaceToolsDockWidgetProps) => { @@ -99,6 +59,7 @@ export const SpaceToolsDockWidget = ({ const [openPopover, setOpenPopover] = useState(null); const [utilityPanel, setUtilityPanel] = useState(null); const [noteDraft, setNoteDraft] = useState(''); + const [plan, setPlan] = useState('normal'); const [isIdle, setIdle] = useState(false); const selectedSoundLabel = useMemo(() => { @@ -112,7 +73,7 @@ export const SpaceToolsDockWidget = ({ return; } - const handleEscape = (event: KeyboardEvent) => { + const handleEscape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') { setOpenPopover(null); } @@ -176,11 +137,126 @@ export const SpaceToolsDockWidget = ({ return; } - onCaptureThought(trimmedNote); + const addedThought = onCaptureThought(trimmedNote); + + if (!addedThought) { + return; + } + setNoteDraft(''); - pushToast({ title: '메모를 잠깐 주차했어요.' }); + pushToast({ + title: '인박스에 저장됨', + durationMs: 4200, + action: { + label: '실행취소', + onClick: () => { + const removed = onDeleteThought(addedThought.id); + + if (!removed) { + return; + } + + pushToast({ title: '저장 취소됨' }); + }, + }, + }); }; + const handleNoteKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + handleNoteSubmit(); + }; + + const handleInboxComplete = (thought: RecentThought) => { + const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted); + + if (!previousThought) { + return; + } + + const willBeCompleted = !thought.isCompleted; + + pushToast({ + title: willBeCompleted ? '완료 처리됨' : '완료 해제됨', + durationMs: 4200, + action: { + label: '실행취소', + onClick: () => { + onRestoreThought(previousThought); + pushToast({ title: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' }); + }, + }, + }); + }; + + const handleInboxDelete = (thought: RecentThought) => { + const removedThought = onDeleteThought(thought.id); + + if (!removedThought) { + return; + } + + pushToast({ + title: '삭제됨', + durationMs: 4200, + action: { + label: '실행취소', + onClick: () => { + onRestoreThought(removedThought); + pushToast({ title: '삭제를 취소했어요.' }); + }, + }, + }); + }; + + const handleInboxClear = () => { + const snapshot = onClearInbox(); + + if (snapshot.length === 0) { + pushToast({ title: '인박스가 비어 있어요.' }); + return; + } + + pushToast({ + title: '모두 비워짐', + durationMs: 4200, + action: { + label: '실행취소', + onClick: () => { + onRestoreThoughts(snapshot); + pushToast({ title: '인박스를 복구했어요.' }); + }, + }, + }); + }; + + const handlePlanPillClick = () => { + if (plan === 'pro') { + openUtilityPanel('manage-plan'); + return; + } + + openUtilityPanel('paywall'); + }; + + const handleLockedClick = (source: string) => { + pushToast({ title: `${source}은(는) PRO 기능이에요.` }); + openUtilityPanel('paywall'); + }; + + const handleStartPro = () => { + setPlan('pro'); + pushToast({ title: '결제(더미)' }); + openUtilityPanel('control-center'); + }; + + const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') => + applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast }); + return ( <> {openPopover ? ( @@ -206,6 +282,41 @@ export const SpaceToolsDockWidget = ({ {isFocusMode ? ( <> +
    +
    +
    + + +
    +
    +
    +
    setNoteDraft(event.target.value)} - placeholder="한 줄 메모" + 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" />
    -
      - {thoughts.slice(0, 3).map((thought) => ( -
    • - {thought.text} -
    • - ))} - {thoughts.length === 0 ? ( -
    • - 아직 메모가 없어요. -
    • - ) : null} -
    -
    - - - -
    +

    나중에 인박스에서 정리해요.

    ) : null} @@ -345,10 +420,10 @@ export const SpaceToolsDockWidget = ({
    @@ -361,25 +436,23 @@ export const SpaceToolsDockWidget = ({ + ) : undefined + } onClose={() => setUtilityPanel(null)} > - {utilityPanel === 'inbox' ? ( - { - onClearInbox(); - pushToast({ title: '인박스를 비웠어요 (더미)' }); - }} - /> - ) : null} - - {utilityPanel === 'stats' ? : null} - {utilityPanel === 'settings' ? ( - { onRoomSelect(roomId); pushToast({ title: '공간을 바꿨어요.' }); @@ -388,6 +461,36 @@ export const SpaceToolsDockWidget = ({ onTimerSelect(label); pushToast({ title: `타이머를 ${label}로 바꿨어요.` }); }} + onSelectSound={(presetId) => { + onSelectPreset(presetId); + pushToast({ title: '사운드를 바꿨어요.' }); + }} + onApplyPack={handleApplyPack} + onLockedClick={handleLockedClick} + /> + ) : null} + + {utilityPanel === 'inbox' ? ( + + ) : null} + + {utilityPanel === 'paywall' ? ( + setUtilityPanel(null)} + /> + ) : null} + + {utilityPanel === 'manage-plan' ? ( + setUtilityPanel(null)} + onManage={() => pushToast({ title: '구독 관리(더미)' })} + onRestore={() => pushToast({ title: '구매 복원(더미)' })} /> ) : null} diff --git a/src/widgets/space-tools-dock/ui/constants.tsx b/src/widgets/space-tools-dock/ui/constants.tsx new file mode 100644 index 0000000..1f2e65a --- /dev/null +++ b/src/widgets/space-tools-dock/ui/constants.tsx @@ -0,0 +1,85 @@ +import type { SpaceUtilityPanelId } from '../model/types'; + +export const ANCHOR_ICON = { + sound: ( + + + + + ), + notes: ( + + + + + + ), +}; + +export const RAIL_ICON = { + controlCenter: ( + + + + + + + ), + inbox: ( + + + + + ), +}; + +export const UTILITY_PANEL_TITLE: Record = { + 'control-center': 'Quick Controls', + inbox: '인박스', + paywall: 'PRO', + 'manage-plan': '플랜 관리', +}; + +export const formatThoughtCount = (count: number) => { + if (count < 1) { + return '0'; + } + + if (count > 9) { + return '9+'; + } + + return String(count); +}; diff --git a/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx index 9a448eb..986e580 100644 --- a/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx +++ b/src/widgets/space-tools-dock/ui/panels/InboxToolPanel.tsx @@ -1,25 +1,67 @@ +import { useState } from 'react'; import type { RecentThought } from '@/entities/session'; import { InboxList } from '@/features/inbox'; interface InboxToolPanelProps { thoughts: RecentThought[]; + onCompleteThought: (thought: RecentThought) => void; + onDeleteThought: (thought: RecentThought) => void; onClear: () => void; } -export const InboxToolPanel = ({ thoughts, onClear }: InboxToolPanelProps) => { +export const InboxToolPanel = ({ + thoughts, + onCompleteThought, + onDeleteThought, + onClear, +}: InboxToolPanelProps) => { + const [confirmOpen, setConfirmOpen] = useState(false); + return ( -
    +

    나중에 모아보는 읽기 전용 인박스

    - + + + {confirmOpen ? ( +
    +
    +

    정말 인박스를 비울까요?

    +

    실수라면 토스트에서 실행취소할 수 있어요.

    +
    + + +
    +
    +
    + ) : null}
    ); }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index bfd8dcd..a41cc24 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -55,7 +55,16 @@ const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => { export const SpaceWorkspaceWidget = () => { const searchParams = useSearchParams(); const { pushToast } = useToast(); - const { thoughts, thoughtCount, addThought, clearThoughts } = useThoughtInbox(); + const { + thoughts, + thoughtCount, + addThought, + removeThought, + clearThoughts, + restoreThought, + restoreThoughts, + setThoughtCompleted, + } = useThoughtInbox(); const initialRoomId = resolveInitialRoomId(searchParams.get('room')); const initialGoal = searchParams.get('goal')?.trim() ?? ''; @@ -204,6 +213,10 @@ export const SpaceWorkspaceWidget = () => { onTimerSelect={setSelectedTimerLabel} onSelectPreset={setSelectedPresetId} onCaptureThought={(note) => addThought(note, selectedRoom.name)} + onDeleteThought={removeThought} + onSetThoughtCompleted={setThoughtCompleted} + onRestoreThought={restoreThought} + onRestoreThoughts={restoreThoughts} onClearInbox={clearThoughts} onExitRequested={handleExitRequested} />