refactor(space): 단일 워크스페이스 Setup→Focus 전환 구조 도입
맥락: - 허브를 경유하는 흐름 대신 /space 한 화면에서 설정과 몰입을 이어서 처리할 필요가 있었습니다. - View 로직 분리와 파일 분할 기준을 지키면서 도크/시트 패턴을 통합해야 했습니다. 변경사항: - /space를 Setup(기본)과 Focus(시작 후) 2상태로 운영하는 space-workspace 위젯을 추가했습니다. - Setup Drawer를 추가해 Space 선택, Goal(필수), Sound(선택) 섹션과 하단 고정 CTA를 구성했습니다. - Goal 입력이 비어있으면 시작하기 버튼이 비활성화되도록 UI 검증을 반영했습니다. - Focus 상태에서 하단 HUD만 유지하고 우측 Tools Dock(🎧/📝/📨/📊/⚙) + 우측 시트 패턴을 적용했습니다. - Notes(쓰기)와 Inbox(읽기) 패널을 분리하고 더미 토스트 동작을 연결했습니다. - FSD 분리를 위해 features(space-select/session-goal/inbox)와 widgets(space-workspace/space-setup-drawer/space-focus-hud/space-sheet-shell)를 추가했습니다. - 기존 space-shell은 신규 워크스페이스로 연결되는 얇은 래퍼로 정리했습니다. 검증: - npx tsc --noEmit - npm run build 세션-상태: /space 단일 워크스페이스에서 Setup→Focus 전환이 동작합니다. 세션-다음: 진입 경로를 /space로 통일하고 레거시 /app 라우트를 정리합니다. 세션-리스크: useSearchParams 기반 초기값은 클라이언트 최초 렌더 기준으로만 반영됩니다.
This commit is contained in:
1
src/features/inbox/index.ts
Normal file
1
src/features/inbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/InboxList';
|
||||
38
src/features/inbox/ui/InboxList.tsx
Normal file
38
src/features/inbox/ui/InboxList.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface InboxListProps {
|
||||
thoughts: RecentThought[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const InboxList = ({ thoughts, className }: InboxListProps) => {
|
||||
if (thoughts.length === 0) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'rounded-2xl border border-white/14 bg-white/6 px-3.5 py-3 text-sm leading-relaxed text-white/74',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
지금은 비어 있어요. 집중 중 떠오른 생각을 여기로 주차할 수 있어요.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={cn('space-y-2.5', className)}>
|
||||
{thoughts.slice(0, 10).map((thought) => (
|
||||
<li
|
||||
key={thought.id}
|
||||
className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3"
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-white/88">{thought.text}</p>
|
||||
<p className="mt-1.5 text-[11px] text-white/54">
|
||||
{thought.roomName} · {thought.capturedAt}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
1
src/features/session-goal/index.ts
Normal file
1
src/features/session-goal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SessionGoalField';
|
||||
57
src/features/session-goal/ui/SessionGoalField.tsx
Normal file
57
src/features/session-goal/ui/SessionGoalField.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { GoalChip } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface SessionGoalFieldProps {
|
||||
goalInput: string;
|
||||
selectedGoalId: string | null;
|
||||
goalChips: GoalChip[];
|
||||
onGoalChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
}
|
||||
|
||||
export const SessionGoalField = ({
|
||||
goalInput,
|
||||
selectedGoalId,
|
||||
goalChips,
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
}: SessionGoalFieldProps) => {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="space-goal-input" className="text-xs font-medium text-white/86">
|
||||
목표 <span className="text-sky-100">(필수)</span>
|
||||
</label>
|
||||
<input
|
||||
id="space-goal-input"
|
||||
value={goalInput}
|
||||
onChange={(event) => onGoalChange(event.target.value)}
|
||||
placeholder="예: 계약서 1페이지 정리"
|
||||
className="h-11 w-full rounded-xl border border-white/18 bg-slate-950/48 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/58 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{goalChips.map((chip) => {
|
||||
const selected = chip.id === selectedGoalId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
onClick={() => onGoalChipSelect(chip)}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||
selected
|
||||
? 'border-sky-200/56 bg-sky-200/20 text-sky-50'
|
||||
: 'border-white/18 bg-white/8 text-white/76 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/features/space-select/index.ts
Normal file
1
src/features/space-select/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SpaceSelectCarousel';
|
||||
51
src/features/space-select/ui/SpaceSelectCarousel.tsx
Normal file
51
src/features/space-select/ui/SpaceSelectCarousel.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface SpaceSelectCarouselProps {
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
onSelect: (roomId: string) => void;
|
||||
}
|
||||
|
||||
export const SpaceSelectCarousel = ({
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelect,
|
||||
}: SpaceSelectCarouselProps) => {
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<div className="flex min-w-full gap-2.5">
|
||||
{rooms.map((room) => {
|
||||
const selected = room.id === selectedRoomId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(room.id)}
|
||||
className={cn(
|
||||
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
|
||||
selected
|
||||
? 'border-sky-200/62 shadow-[0_0_0_1px_rgba(186,230,253,0.4),0_0_18px_rgba(56,189,248,0.22)]'
|
||||
: 'border-white/18 hover:border-white/32',
|
||||
)}
|
||||
style={getRoomCardBackgroundStyle(room)}
|
||||
aria-label={`${room.name} 선택`}
|
||||
>
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.12)_0%,rgba(2,6,23,0.76)_100%)]" />
|
||||
{selected ? (
|
||||
<span className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-200/64 bg-sky-200/24 text-[10px] font-semibold text-sky-50">
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
<div className="absolute inset-x-2 bottom-2">
|
||||
<p className="truncate text-sm font-semibold text-white">{room.name}</p>
|
||||
<p className="truncate text-[11px] text-white/72">{room.vibeLabel}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user