refactor(control-center): Quick Controls 재디자인 및 플랜/잠금 결제 동선 정리

This commit is contained in:
2026-03-04 14:36:38 +09:00
parent 60cd093308
commit 3cddd3c1f4
21 changed files with 983 additions and 149 deletions

View File

@@ -0,0 +1 @@
export * from './ui/ControlCenterSheetWidget';

View File

@@ -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 (
<span className="absolute right-2 top-2 rounded-full border border-white/20 bg-black/46 px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.08em] text-white/86">
LOCK PRO
</span>
);
};
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
return (
<header className="flex items-end justify-between gap-2">
<h4 className="text-sm font-semibold text-white/90">{title}</h4>
<p className="text-[11px] text-white/56">{description}</p>
</header>
);
};
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 (
<div className="space-y-4">
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
<SectionTitle title="Scene" description={selectedRoom?.name ?? '공간'} />
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{rooms.slice(0, 6).map((room) => {
const selected = room.id === selectedRoomId;
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
return (
<button
key={room.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`공간: ${room.name}`);
return;
}
onSelectRoom(room.id);
}}
className={cn(
'relative h-24 w-[130px] shrink-0 overflow-hidden rounded-xl border text-left transition-transform hover:-translate-y-0.5',
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
)}
>
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
{locked ? <LockBadge /> : null}
<div className="absolute inset-x-2 bottom-2 min-w-0">
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
</div>
</button>
);
})}
</div>
</section>
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
<SectionTitle title="Time" description={selectedTimerLabel} />
<div className="grid grid-cols-3 gap-1.5">
{timerPresets.slice(0, 3).map((preset) => {
const selected = preset.label === selectedTimerLabel;
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
return (
<button
key={preset.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`타이머: ${preset.label}`);
return;
}
onSelectTimer(preset.label);
}}
className={cn(
'relative rounded-xl border px-2 py-2 text-xs transition-colors',
selected
? 'border-sky-200/42 bg-sky-200/16 text-white'
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
)}
>
{preset.label}
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
</button>
);
})}
</div>
</section>
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
<SectionTitle title="Sound" description={selectedSound?.label ?? '기본'} />
<div className="flex flex-wrap gap-1.5">
{soundPresets.slice(0, 6).map((preset) => {
const selected = preset.id === selectedSoundPresetId;
const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
return (
<button
key={preset.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`사운드: ${preset.label}`);
return;
}
onSelectSound(preset.id);
}}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
selected
? 'border-sky-200/42 bg-sky-200/16 text-white'
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
)}
>
{preset.label}
{locked ? <span className="ml-1.5 text-[10px] text-white/66">LOCK PRO</span> : null}
</button>
);
})}
</div>
</section>
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
<SectionTitle title="Preset Packs" description="원탭 조합" />
<div className="grid gap-1.5">
{QUICK_PACKS.map((pack) => {
const locked = !isPro && pack.locked;
return (
<button
key={pack.id}
type="button"
onClick={() => {
if (locked) {
onLockedClick(`프리셋 팩: ${pack.name}`);
return;
}
onApplyPack(pack.id);
}}
className="relative rounded-xl border border-white/16 bg-white/[0.04] px-3 py-2 text-left transition-colors hover:bg-white/[0.1]"
>
{locked ? <LockBadge /> : null}
<p className="text-sm font-medium text-white/90">{pack.name}</p>
<p className="mt-0.5 text-[11px] text-white/62">{pack.combo}</p>
</button>
);
})}
</div>
</section>
</div>
);
};

View File

@@ -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<number | null>(null);
const [shouldRender, setShouldRender] = useState(open);
@@ -123,16 +125,19 @@ export const SpaceSideSheet = ({
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
</div>
{dismissible ? (
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
>
</button>
) : null}
<div className="flex items-center gap-2">
{headerAction}
{dismissible ? (
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
>
</button>
) : null}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>

View File

@@ -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 팩을 적용했어요.' });
};

View File

@@ -1,2 +1,2 @@
export type SpaceAnchorPopoverId = 'sound' | 'notes';
export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats';
export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall';

View File

@@ -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: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
<path d="M16 9a4 4 0 0 1 0 6" />
</svg>
),
notes: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h9l3 3v13H6z" />
<path d="M15 4v4h4" />
<path d="M9 12h6M9 16h4" />
</svg>
),
};
const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
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<SpaceAnchorPopoverId | null>(null);
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
const [noteDraft, setNoteDraft] = useState('');
const [plan, setPlan] = useState<PlanTier>('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<HTMLInputElement>) => {
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 ? (
<>
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<button
type="button"
aria-label="인박스 열기"
title="인박스"
onClick={() => openUtilityPanel('inbox')}
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<button
type="button"
aria-label="Quick Controls 열기"
title="Quick Controls"
onClick={() => openUtilityPanel('control-center')}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
>
{RAIL_ICON.controlCenter}
</button>
</div>
</div>
</div>
<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)]',
@@ -237,7 +348,8 @@ export const SpaceToolsDockWidget = ({
<input
value={noteDraft}
onChange={(event) => 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"
/>
<button
@@ -248,44 +360,7 @@ export const SpaceToolsDockWidget = ({
</button>
</div>
<ul className="mt-2 space-y-1.5">
{thoughts.slice(0, 3).map((thought) => (
<li
key={thought.id}
className="truncate rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/74"
>
{thought.text}
</li>
))}
{thoughts.length === 0 ? (
<li className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/56">
.
</li>
) : null}
</ul>
<div className="mt-2 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => openUtilityPanel('inbox')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
<button
type="button"
onClick={() => openUtilityPanel('stats')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
<button
type="button"
onClick={() => openUtilityPanel('settings')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
</button>
</div>
<p className="mt-2 text-[11px] text-white/52"> .</p>
</div>
) : null}
</div>
@@ -345,10 +420,10 @@ export const SpaceToolsDockWidget = ({
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => openUtilityPanel('settings')}
onClick={() => openUtilityPanel('control-center')}
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
>
Control Center
</button>
</div>
</div>
@@ -361,25 +436,23 @@ export const SpaceToolsDockWidget = ({
<SpaceSideSheet
open={utilityPanel !== null}
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
headerAction={
utilityPanel === 'control-center' ? (
<PlanPill plan={plan} onClick={handlePlanPillClick} />
) : undefined
}
onClose={() => setUtilityPanel(null)}
>
{utilityPanel === 'inbox' ? (
<InboxToolPanel
thoughts={thoughts}
onClear={() => {
onClearInbox();
pushToast({ title: '인박스를 비웠어요 (더미)' });
}}
/>
) : null}
{utilityPanel === 'stats' ? <StatsToolPanel /> : null}
{utilityPanel === 'settings' ? (
<SettingsToolPanel
{utilityPanel === 'control-center' ? (
<ControlCenterSheetWidget
plan={plan}
rooms={rooms}
selectedRoomId={selectedRoomId}
selectedTimerLabel={selectedTimerLabel}
selectedSoundPresetId={selectedPresetId}
timerPresets={timerPresets}
soundPresets={SOUND_PRESETS}
onSelectRoom={(roomId) => {
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' ? (
<InboxToolPanel
thoughts={thoughts}
onCompleteThought={handleInboxComplete}
onDeleteThought={handleInboxDelete}
onClear={handleInboxClear}
/>
) : null}
{utilityPanel === 'paywall' ? (
<PaywallSheetContent
onStartPro={handleStartPro}
onClose={() => setUtilityPanel(null)}
/>
) : null}
{utilityPanel === 'manage-plan' ? (
<ManagePlanSheetContent
onClose={() => setUtilityPanel(null)}
onManage={() => pushToast({ title: '구독 관리(더미)' })}
onRestore={() => pushToast({ title: '구매 복원(더미)' })}
/>
) : null}
</SpaceSideSheet>

View File

@@ -0,0 +1,85 @@
import type { SpaceUtilityPanelId } from '../model/types';
export const ANCHOR_ICON = {
sound: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
<path d="M16 9a4 4 0 0 1 0 6" />
</svg>
),
notes: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4h9l3 3v13H6z" />
<path d="M15 4v4h4" />
<path d="M9 12h6M9 16h4" />
</svg>
),
};
export const RAIL_ICON = {
controlCenter: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16M4 12h16M4 17h16" />
<circle cx="8" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="16" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="11" cy="17" r="1.2" fill="currentColor" stroke="none" />
</svg>
),
inbox: (
<svg
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="4" y="6" width="16" height="12" rx="2.5" />
<path d="m5 8 7 5 7-5" />
</svg>
),
};
export const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
'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);
};

View File

@@ -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 (
<div className="space-y-3.5">
<div className="relative space-y-3.5">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-white/58"> </p>
<button
type="button"
onClick={onClear}
onClick={() => setConfirmOpen(true)}
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
>
</button>
</div>
<InboxList thoughts={thoughts} />
<InboxList
thoughts={thoughts}
onCompleteThought={onCompleteThought}
onDeleteThought={onDeleteThought}
/>
{confirmOpen ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-slate-950/70 px-3 backdrop-blur-sm">
<div className="w-full max-w-[272px] rounded-2xl border border-white/14 bg-slate-900/92 p-3.5 shadow-xl shadow-slate-950/45">
<p className="text-sm font-medium text-white/92"> ?</p>
<p className="mt-1 text-[11px] text-white/60"> .</p>
<div className="mt-3 flex justify-end gap-1.5">
<button
type="button"
onClick={() => setConfirmOpen(false)}
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
>
</button>
<button
type="button"
onClick={() => {
onClear();
setConfirmOpen(false);
}}
className="rounded-full border border-rose-200/34 bg-rose-200/16 px-2.5 py-1 text-[11px] text-rose-100/92 transition-colors hover:bg-rose-200/24"
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -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}
/>