diff --git a/README.md b/README.md
index e215bc4..ac75fcb 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,49 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# VibeRoom Web
-## Getting Started
+Next.js(App Router) + TypeScript + TailwindCSS 기반 VibeRoom 프론트엔드입니다.
-First, run the development server:
+## 실행 방법
```bash
+npm install
npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+브라우저에서 `http://localhost:3000` 접속
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+## 주요 라우트
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+- `/` : Landing 페이지 (`src/app/(landing)/page.tsx`)
+- `/app` : 가상공간 입장 허브
+- `/space` : 집중공간 스켈레톤
+- `/stats` : 집중 통계 목업
+- `/settings` : 설정 목업
+- `/login` : 로그인
-## Learn More
+## 구조
-To learn more about Next.js, take a look at the following resources:
+FSD 스타일로 `src/` 하위 레이어를 사용합니다.
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+- `src/entities` : room/user/session 타입 + 더미 데이터
+- `src/features` : room-select/check-in/reactions/custom-entry-modal/profile-menu/distraction-dump
+- `src/widgets` : 페이지 섹션 단위 UI (TopBar, StartRitual, RoomsGallery, SpaceShell 등)
+- `src/shared` : 공용 UI, 유틸, 설정
+- `src/app` : App Router 페이지 조합
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+## 구현 범위
-## Deploy on Vercel
+이번 변경은 **UI 목업** 중심입니다.
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+- 타이머 카운트다운/오디오 재생/실시간/DB 저장/API 호출 미구현
+- 선택 하이라이트, 패널 토글, 모달 탭, 드롭다운, 라우팅, 토스트만 구현
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+## 문서(작업 재개용)
+
+세션이 끊겨도 같은 품질로 이어서 작업하려면 아래 문서를 먼저 확인합니다.
+
+- `docs/README.md`
+- `docs/00_project_brief.md`
+- `docs/01_ui_guidelines.md`
+- `docs/02_arch_fsd_rules.md`
+- `docs/03_routes_map.md`
+- `docs/90_current_state.md`
diff --git a/src/app/(app)/app/page.tsx b/src/app/(app)/app/page.tsx
new file mode 100644
index 0000000..92aa97a
--- /dev/null
+++ b/src/app/(app)/app/page.tsx
@@ -0,0 +1,5 @@
+import { AppHubWidget } from '@/widgets/app-hub';
+
+export default function AppPage() {
+ return ;
+}
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
deleted file mode 100644
index ffacca9..0000000
--- a/src/app/(app)/dashboard/page.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client";
-
-import { UserProfileSimple, useUserProfile } from "@/features/user";
-import { useAuthStore } from "@/store/useAuthStore";
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-
-export default function DashboardPage() {
- const router = useRouter();
- const { logout, isAuthenticated } = useAuthStore();
- const { user, isLoading } = useUserProfile();
- const [focusGoal, setFocusGoal] = useState("");
-
- useEffect(() => {
- if (!isAuthenticated) {
- router.push("/login");
- }
- }, [isAuthenticated, router]);
-
- const handleLogout = () => {
- logout();
- router.push("/login");
- };
-
- return (
-
-
-
-
-
-
- V
-
-
-
-
- 지금, 몰입을 시작해보세요
-
-
- VibeRoom은 집중 루틴을 빠르게 시작하는 앱 홈입니다. 가상공간을
- 선택하고 바로 세션에 들어가세요.
-
-
-
-
- 여기서 집중해보세요! 세션 시작과 동시에 공간 톤과 사운드가
- 맞춰집니다.
-
-
-
-
-
setFocusGoal(event.target.value)}
- placeholder="오늘 무엇에 집중할까요?"
- className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-brand-dark placeholder:text-brand-dark/45 focus:border-brand-primary/45 focus:bg-white focus:outline-none sm:flex-1"
- />
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx
new file mode 100644
index 0000000..90d81f3
--- /dev/null
+++ b/src/app/(app)/settings/page.tsx
@@ -0,0 +1,5 @@
+import { SettingsPanelWidget } from '@/widgets/settings-panel';
+
+export default function SettingsPage() {
+ return ;
+}
diff --git a/src/app/(app)/space/page.tsx b/src/app/(app)/space/page.tsx
new file mode 100644
index 0000000..1f92dcb
--- /dev/null
+++ b/src/app/(app)/space/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { SpaceSkeletonWidget } from '@/widgets/space-shell';
+
+export default function SpacePage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/src/app/(app)/stats/page.tsx b/src/app/(app)/stats/page.tsx
new file mode 100644
index 0000000..4944081
--- /dev/null
+++ b/src/app/(app)/stats/page.tsx
@@ -0,0 +1,5 @@
+import { StatsOverviewWidget } from '@/widgets/stats-overview';
+
+export default function StatsPage() {
+ return ;
+}
diff --git a/src/app/(marketing)/page.tsx b/src/app/(landing)/page.tsx
similarity index 100%
rename from src/app/(marketing)/page.tsx
rename to src/app/(landing)/page.tsx
diff --git a/src/app/globals.css b/src/app/globals.css
index 1b08a78..ec5b457 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -15,3 +15,25 @@ body {
background-color: theme('colors.slate.50'); /* #f8fafc */
color: var(--color-brand-dark);
}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes sheet-in {
+ from {
+ opacity: 0;
+ transform: translateX(28px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5d8cea0..7b90825 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { Noto_Sans_KR } from 'next/font/google';
import './globals.css';
+import { Providers } from './providers';
// 1. Noto Sans KR 폰트 설정 (라틴어, 프랑스어, 한국어 등 다국어 지원 베이스)
const notoSans = Noto_Sans_KR({
@@ -23,7 +24,7 @@ export default function RootLayout({
return (
- {children}
+ {children}
);
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
new file mode 100644
index 0000000..35b2c40
--- /dev/null
+++ b/src/app/providers.tsx
@@ -0,0 +1,8 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { ToastProvider } from '@/shared/ui';
+
+export const Providers = ({ children }: { children: ReactNode }) => {
+ return {children} ;
+};
diff --git a/src/entities/room/index.ts b/src/entities/room/index.ts
new file mode 100644
index 0000000..4f02999
--- /dev/null
+++ b/src/entities/room/index.ts
@@ -0,0 +1,2 @@
+export * from './model/rooms';
+export * from './model/types';
diff --git a/src/entities/room/model/rooms.ts b/src/entities/room/model/rooms.ts
new file mode 100644
index 0000000..88c54fc
--- /dev/null
+++ b/src/entities/room/model/rooms.ts
@@ -0,0 +1,157 @@
+import type { CSSProperties } from 'react';
+import type { RoomTheme } from './types';
+
+export const ROOM_THEMES: RoomTheme[] = [
+ {
+ id: 'rain-window',
+ name: '비 오는 창가',
+ description: '빗소리 위로 스탠드 조명이 부드럽게 번집니다.',
+ tags: ['저자극', '감성'],
+ recommendedSound: 'Rain Focus',
+ recommendedTime: '밤',
+ vibeLabel: '잔잔함',
+ activeMembers: 32,
+ presence: { focus: 23, break: 6, away: 3 },
+ previewImage: '/spaces/rain-window.jpg',
+ previewGradient:
+ 'radial-gradient(120% 130% at 12% 8%, rgba(59,130,246,0.35) 0%, rgba(2,6,23,0) 48%), linear-gradient(160deg, rgba(15,23,42,0.96) 0%, rgba(2,6,23,0.92) 52%, rgba(10,10,20,0.96) 100%)',
+ },
+ {
+ id: 'dawn-cafe',
+ name: '새벽 카페',
+ description: '첫 커피 향처럼 잔잔하고 따뜻한 좌석.',
+ tags: ['감성', '딥워크'],
+ recommendedSound: 'Cafe Murmur',
+ recommendedTime: '새벽',
+ vibeLabel: '포근함',
+ activeMembers: 28,
+ presence: { focus: 18, break: 7, away: 3 },
+ previewImage: '/spaces/dawn-cafe.jpg',
+ previewGradient:
+ 'radial-gradient(130% 120% at 18% 0%, rgba(251,191,36,0.38) 0%, rgba(15,23,42,0) 52%), linear-gradient(150deg, rgba(67,20,7,0.85) 0%, rgba(30,41,59,0.86) 58%, rgba(2,6,23,0.95) 100%)',
+ },
+ {
+ id: 'quiet-library',
+ name: '도서관',
+ description: '넘기는 종이 소리만 들리는 정돈된 책상.',
+ tags: ['저자극', '딥워크'],
+ recommendedSound: 'Deep White',
+ recommendedTime: '오후',
+ vibeLabel: '몰입',
+ activeMembers: 41,
+ presence: { focus: 31, break: 7, away: 3 },
+ previewImage: '/spaces/library.jpg',
+ previewGradient:
+ 'radial-gradient(110% 100% at 20% 10%, rgba(14,116,144,0.36) 0%, rgba(30,41,59,0) 55%), linear-gradient(160deg, rgba(12,32,52,0.95) 0%, rgba(30,41,59,0.9) 52%, rgba(2,6,23,0.97) 100%)',
+ },
+ {
+ id: 'wave-sound',
+ name: '파도 소리',
+ description: '잔잔한 해변 위로 호흡을 고르는 공간.',
+ tags: ['움직임 적음', '감성'],
+ recommendedSound: 'Ocean Breath',
+ recommendedTime: '밤',
+ vibeLabel: '차분함',
+ activeMembers: 19,
+ presence: { focus: 13, break: 4, away: 2 },
+ previewImage: '/spaces/ocean.jpg',
+ previewGradient:
+ 'radial-gradient(125% 125% at 75% 8%, rgba(56,189,248,0.4) 0%, rgba(15,23,42,0) 50%), linear-gradient(145deg, rgba(8,47,73,0.93) 0%, rgba(15,23,42,0.9) 55%, rgba(3,7,18,0.96) 100%)',
+ },
+ {
+ id: 'green-forest',
+ name: '숲',
+ description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
+ tags: ['저자극', '움직임 적음'],
+ recommendedSound: 'Forest Hush',
+ recommendedTime: '오전',
+ vibeLabel: '맑음',
+ activeMembers: 26,
+ presence: { focus: 17, break: 6, away: 3 },
+ previewImage: '/spaces/forest.jpg',
+ previewGradient:
+ 'radial-gradient(130% 110% at 15% 0%, rgba(74,222,128,0.3) 0%, rgba(2,6,23,0) 52%), linear-gradient(155deg, rgba(22,78,99,0.88) 0%, rgba(20,83,45,0.87) 48%, rgba(2,6,23,0.95) 100%)',
+ },
+ {
+ id: 'fireplace',
+ name: '벽난로',
+ description: '작은 불꽃이 주는 리듬으로 집중을 붙잡습니다.',
+ tags: ['감성', '저자극'],
+ recommendedSound: 'Fireplace',
+ recommendedTime: '밤',
+ vibeLabel: '온기',
+ activeMembers: 21,
+ presence: { focus: 15, break: 4, away: 2 },
+ previewImage: '/spaces/fireplace.jpg',
+ previewGradient:
+ 'radial-gradient(125% 130% at 70% 0%, rgba(251,146,60,0.42) 0%, rgba(15,23,42,0) 55%), linear-gradient(150deg, rgba(69,26,3,0.9) 0%, rgba(31,41,55,0.86) 58%, rgba(2,6,23,0.95) 100%)',
+ },
+ {
+ id: 'city-night',
+ name: '도시 야경',
+ description: '유리창 너머 야경이 멀리 흐르는 고요한 밤.',
+ tags: ['딥워크', '감성'],
+ recommendedSound: 'Night Lo-fi',
+ recommendedTime: '심야',
+ vibeLabel: '고요함',
+ activeMembers: 34,
+ presence: { focus: 24, break: 7, away: 3 },
+ previewImage: '/spaces/city-night.jpg',
+ previewGradient:
+ 'radial-gradient(120% 120% at 82% 12%, rgba(192,132,252,0.34) 0%, rgba(15,23,42,0) 50%), linear-gradient(160deg, rgba(12,18,48,0.96) 0%, rgba(30,41,59,0.9) 52%, rgba(2,6,23,0.98) 100%)',
+ },
+ {
+ id: 'snow-mountain',
+ name: '설산',
+ description: '차분한 공기와 선명한 수평선이 머리를 맑게 합니다.',
+ tags: ['움직임 적음', '딥워크'],
+ recommendedSound: 'Cold Wind',
+ recommendedTime: '새벽',
+ vibeLabel: '선명함',
+ activeMembers: 15,
+ presence: { focus: 11, break: 3, away: 1 },
+ previewImage: '/spaces/snow-mountain.jpg',
+ previewGradient:
+ 'radial-gradient(120% 110% at 18% 0%, rgba(125,211,252,0.38) 0%, rgba(15,23,42,0) 52%), linear-gradient(165deg, rgba(15,23,42,0.9) 0%, rgba(30,41,59,0.9) 60%, rgba(2,6,23,0.97) 100%)',
+ },
+ {
+ id: 'sun-window',
+ name: '창가',
+ description: '햇살이 들어오는 간결한 책상, 부담 없는 시작.',
+ tags: ['저자극', '딥워크'],
+ recommendedSound: 'Soft Daylight',
+ recommendedTime: '오후',
+ vibeLabel: '가벼움',
+ activeMembers: 27,
+ presence: { focus: 18, break: 6, away: 3 },
+ previewImage: '/spaces/sun-window.jpg',
+ previewGradient:
+ 'radial-gradient(115% 95% at 10% 0%, rgba(253,224,71,0.34) 0%, rgba(15,23,42,0) 52%), linear-gradient(150deg, rgba(30,58,138,0.86) 0%, rgba(30,41,59,0.86) 48%, rgba(2,6,23,0.96) 100%)',
+ },
+ {
+ id: 'outer-space',
+ name: '우주',
+ description: '별빛만 남긴 어둠 속에서 깊게 잠수합니다.',
+ tags: ['딥워크', '감성'],
+ recommendedSound: 'Deep Drone',
+ recommendedTime: '심야',
+ vibeLabel: '깊음',
+ activeMembers: 23,
+ presence: { focus: 17, break: 4, away: 2 },
+ previewImage: '/spaces/outer-space.jpg',
+ previewGradient:
+ 'radial-gradient(120% 120% at 75% 15%, rgba(59,130,246,0.33) 0%, rgba(15,23,42,0) 56%), linear-gradient(155deg, rgba(15,23,42,0.98) 0%, rgba(49,46,129,0.85) 55%, rgba(2,6,23,0.98) 100%)',
+ },
+];
+
+export const getRoomById = (roomId: string) => {
+ return ROOM_THEMES.find((room) => room.id === roomId);
+};
+
+export const getRoomBackgroundStyle = (room: RoomTheme): CSSProperties => {
+ return {
+ backgroundImage: `${room.previewGradient}, url('${room.previewImage}')`,
+ backgroundSize: 'cover, cover',
+ backgroundPosition: 'center, center',
+ };
+};
diff --git a/src/entities/room/model/types.ts b/src/entities/room/model/types.ts
new file mode 100644
index 0000000..8ff92f0
--- /dev/null
+++ b/src/entities/room/model/types.ts
@@ -0,0 +1,21 @@
+export type RoomTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
+
+export interface RoomPresence {
+ focus: number;
+ break: number;
+ away: number;
+}
+
+export interface RoomTheme {
+ id: string;
+ name: string;
+ description: string;
+ tags: RoomTag[];
+ recommendedSound: string;
+ recommendedTime: string;
+ vibeLabel: string;
+ activeMembers: number;
+ presence: RoomPresence;
+ previewImage: string;
+ previewGradient: string;
+}
diff --git a/src/entities/session/index.ts b/src/entities/session/index.ts
new file mode 100644
index 0000000..65013e8
--- /dev/null
+++ b/src/entities/session/index.ts
@@ -0,0 +1,2 @@
+export * from './model/mockSession';
+export * from './model/types';
diff --git a/src/entities/session/model/mockSession.ts b/src/entities/session/model/mockSession.ts
new file mode 100644
index 0000000..e5bde44
--- /dev/null
+++ b/src/entities/session/model/mockSession.ts
@@ -0,0 +1,68 @@
+import type {
+ CheckInPhrase,
+ FocusStatCard,
+ GoalChip,
+ ReactionOption,
+ SoundPreset,
+ TimerPreset,
+} from './types';
+
+export const TODAY_ONE_LINER = '오늘의 한 줄: 완벽보다 시작, 한 조각이면 충분해요.';
+
+export const GOAL_CHIPS: GoalChip[] = [
+ { id: 'mail-3', label: '메일 3개' },
+ { id: 'doc-1p', label: '문서 1p' },
+ { id: 'code-1-function', label: '코딩 1함수' },
+ { id: 'tidy-10m', label: '정리 10분' },
+ { id: 'reading-15m', label: '독서 15분' },
+ { id: 'resume-1paragraph', label: '이력서 1문단' },
+];
+
+export const CHECK_IN_PHRASES: CheckInPhrase[] = [
+ { id: 'arrived', text: '지금 들어왔어요' },
+ { id: 'sprint-25', text: '25분만 달릴게요' },
+ { id: 'on-break', text: '휴식 중' },
+ { id: 'back-focus', text: '다시 집중!' },
+ { id: 'slow-day', text: '오늘은 천천히' },
+];
+
+export const REACTION_OPTIONS: ReactionOption[] = [
+ { id: 'thumbs-up', emoji: '👍', label: '응원해요' },
+ { id: 'fire', emoji: '🔥', label: '집중 모드' },
+ { id: 'clap', emoji: '👏', label: '잘하고 있어요' },
+ { id: 'heart-hands', emoji: '🫶', label: '연결되어 있어요' },
+];
+
+export const SOUND_PRESETS: SoundPreset[] = [
+ { id: 'deep-white', label: 'Deep White' },
+ { id: 'rain-focus', label: 'Rain Focus' },
+ { id: 'cafe-work', label: 'Cafe Work' },
+ { id: 'ocean-calm', label: 'Ocean Calm' },
+ { id: 'fireplace', label: 'Fireplace' },
+ { id: 'silent', label: 'Silent' },
+];
+
+export const TIMER_PRESETS: TimerPreset[] = [
+ { id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
+ { id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
+ { id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
+ { id: 'custom', label: '커스텀' },
+];
+
+export const DISTRACTION_DUMP_PLACEHOLDER = [
+ '디자인 QA 요청 확인',
+ '세금계산서 발행 메모',
+ '오후 미팅 질문 1개 정리',
+];
+
+export const TODAY_STATS: FocusStatCard[] = [
+ { id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
+ { id: 'today-cycles', label: '완료한 사이클', value: '5회', delta: '+1' },
+ { id: 'today-entry', label: '입장 횟수', value: '3회', delta: '유지' },
+];
+
+export const WEEKLY_STATS: FocusStatCard[] = [
+ { id: 'week-focus', label: '최근 7일 집중 시간', value: '14h 20m', delta: '+2h 10m' },
+ { id: 'week-best-day', label: '최고 몰입일', value: '수요일', delta: '3h 30m' },
+ { id: 'week-consistency', label: '연속 달성', value: '4일', delta: '+1일' },
+];
diff --git a/src/entities/session/model/types.ts b/src/entities/session/model/types.ts
new file mode 100644
index 0000000..da5bd66
--- /dev/null
+++ b/src/entities/session/model/types.ts
@@ -0,0 +1,34 @@
+export interface GoalChip {
+ id: string;
+ label: string;
+}
+
+export interface CheckInPhrase {
+ id: string;
+ text: string;
+}
+
+export interface ReactionOption {
+ id: string;
+ emoji: string;
+ label: string;
+}
+
+export interface SoundPreset {
+ id: string;
+ label: string;
+}
+
+export interface TimerPreset {
+ id: string;
+ label: string;
+ focusMinutes?: number;
+ breakMinutes?: number;
+}
+
+export interface FocusStatCard {
+ id: string;
+ label: string;
+ value: string;
+ delta: string;
+}
diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts
new file mode 100644
index 0000000..fc2a648
--- /dev/null
+++ b/src/entities/user/index.ts
@@ -0,0 +1,2 @@
+export * from './model/mockUser';
+export * from './model/types';
diff --git a/src/entities/user/model/mockUser.ts b/src/entities/user/model/mockUser.ts
new file mode 100644
index 0000000..36c7fb1
--- /dev/null
+++ b/src/entities/user/model/mockUser.ts
@@ -0,0 +1,7 @@
+import type { ViewerProfile } from './types';
+
+export const MOCK_VIEWER: ViewerProfile = {
+ id: 'viewer-1',
+ name: '민서',
+ avatarLabel: 'MS',
+};
diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts
new file mode 100644
index 0000000..694ecb7
--- /dev/null
+++ b/src/entities/user/model/types.ts
@@ -0,0 +1,5 @@
+export interface ViewerProfile {
+ id: string;
+ name: string;
+ avatarLabel: string;
+}
diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts
index 2af0f7e..9e832e0 100644
--- a/src/features/auth/hooks/useSocialLogin.ts
+++ b/src/features/auth/hooks/useSocialLogin.ts
@@ -34,8 +34,8 @@ export const useSocialLogin = () => {
// 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장
useAuthStore.getState().setAuth(response);
- // 3. 메인 대시보드 화면으로 이동
- router.push("/dashboard");
+ // 3. 메인 허브 화면으로 이동
+ router.push("/app");
} catch (err) {
console.error(`[${provider}] 로그인 실패:`, err);
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
@@ -64,7 +64,20 @@ export const useSocialLogin = () => {
*/
const loginWithApple = () => {
try {
- appleAuthHelpers.signIn({
+ const appleHelperBridge = appleAuthHelpers as unknown as {
+ signIn: (options: {
+ authOptions: {
+ clientId: string;
+ scope: string;
+ redirectURI: string;
+ usePopup: boolean;
+ };
+ onSuccess: (response: any) => void;
+ onError: (err: any) => void;
+ }) => void;
+ };
+
+ appleHelperBridge.signIn({
authOptions: {
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || "",
scope: "email name",
diff --git a/src/features/check-in/index.ts b/src/features/check-in/index.ts
new file mode 100644
index 0000000..fcd1531
--- /dev/null
+++ b/src/features/check-in/index.ts
@@ -0,0 +1,3 @@
+export * from './model/useCheckIn';
+export * from './ui/CompactCheckInChips';
+export * from './ui/CheckInChips';
diff --git a/src/features/check-in/model/useCheckIn.ts b/src/features/check-in/model/useCheckIn.ts
new file mode 100644
index 0000000..0b92523
--- /dev/null
+++ b/src/features/check-in/model/useCheckIn.ts
@@ -0,0 +1,16 @@
+'use client';
+
+import { useState } from 'react';
+
+export const useCheckIn = () => {
+ const [lastCheckIn, setLastCheckIn] = useState(null);
+
+ const recordCheckIn = (message: string) => {
+ setLastCheckIn(message);
+ };
+
+ return {
+ lastCheckIn,
+ recordCheckIn,
+ };
+};
diff --git a/src/features/check-in/ui/CheckInChips.tsx b/src/features/check-in/ui/CheckInChips.tsx
new file mode 100644
index 0000000..3d5a3cb
--- /dev/null
+++ b/src/features/check-in/ui/CheckInChips.tsx
@@ -0,0 +1,32 @@
+import type { CheckInPhrase } from '@/entities/session';
+import { Chip } from '@/shared/ui';
+
+interface CheckInChipsProps {
+ phrases: CheckInPhrase[];
+ lastCheckIn: string | null;
+ onCheckIn: (message: string) => void;
+}
+
+export const CheckInChips = ({
+ phrases,
+ lastCheckIn,
+ onCheckIn,
+}: CheckInChipsProps) => {
+ return (
+
+
+ {phrases.map((phrase) => (
+ onCheckIn(phrase.text)}>
+ {phrase.text}
+
+ ))}
+
+
+ 마지막 체크인:{' '}
+
+ {lastCheckIn ?? '아직 남기지 않았어요'}
+
+
+
+ );
+};
diff --git a/src/features/check-in/ui/CompactCheckInChips.tsx b/src/features/check-in/ui/CompactCheckInChips.tsx
new file mode 100644
index 0000000..44d03ed
--- /dev/null
+++ b/src/features/check-in/ui/CompactCheckInChips.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import type { CheckInPhrase } from '@/entities/session';
+import { Chip } from '@/shared/ui';
+
+interface CompactCheckInChipsProps {
+ phrases: CheckInPhrase[];
+ onCheckIn: (message: string) => void;
+ collapsedCount?: number;
+}
+
+export const CompactCheckInChips = ({
+ phrases,
+ onCheckIn,
+ collapsedCount = 3,
+}: CompactCheckInChipsProps) => {
+ const [showAll, setShowAll] = useState(false);
+
+ const visiblePhrases = useMemo(() => {
+ if (showAll) {
+ return phrases;
+ }
+
+ return phrases.slice(0, collapsedCount);
+ }, [collapsedCount, phrases, showAll]);
+
+ return (
+
+
+ {visiblePhrases.map((phrase) => (
+ onCheckIn(phrase.text)}
+ className="!px-2.5 !py-1 text-[11px]"
+ >
+ {phrase.text}
+
+ ))}
+
+
+ {phrases.length > collapsedCount ? (
+
setShowAll((current) => !current)}
+ className="text-[11px] text-white/65 transition hover:text-white"
+ >
+ {showAll ? '접기' : '더보기'}
+
+ ) : null}
+
+ );
+};
diff --git a/src/features/custom-entry-modal/index.ts b/src/features/custom-entry-modal/index.ts
new file mode 100644
index 0000000..f38b7fd
--- /dev/null
+++ b/src/features/custom-entry-modal/index.ts
@@ -0,0 +1,2 @@
+export * from './model/useCustomEntryForm';
+export * from './ui/CustomEntryModal';
diff --git a/src/features/custom-entry-modal/model/useCustomEntryForm.ts b/src/features/custom-entry-modal/model/useCustomEntryForm.ts
new file mode 100644
index 0000000..c176b32
--- /dev/null
+++ b/src/features/custom-entry-modal/model/useCustomEntryForm.ts
@@ -0,0 +1,70 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { SOUND_PRESETS, TIMER_PRESETS } from '@/entities/session';
+
+export type CustomEntryTab = 'theme' | 'sound' | 'timer';
+
+export interface CustomEntrySelection {
+ soundId: string;
+ timerId: string;
+ timerLabel: string;
+}
+
+const getSafeNumber = (value: string, fallback: number) => {
+ const parsed = Number(value);
+
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ return fallback;
+ }
+
+ return Math.round(parsed);
+};
+
+export const useCustomEntryForm = () => {
+ const [activeTab, setActiveTab] = useState('theme');
+ const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
+ const [selectedTimerId, setSelectedTimerId] = useState(TIMER_PRESETS[0].id);
+ const [customFocusMinutes, setCustomFocusMinutes] = useState('40');
+ const [customBreakMinutes, setCustomBreakMinutes] = useState('10');
+
+ const timerLabel = useMemo(() => {
+ if (selectedTimerId !== 'custom') {
+ return TIMER_PRESETS.find((preset) => preset.id === selectedTimerId)?.label ??
+ TIMER_PRESETS[0].label;
+ }
+
+ const focus = getSafeNumber(customFocusMinutes, 25);
+ const breakMinutes = getSafeNumber(customBreakMinutes, 5);
+
+ return `${focus}/${breakMinutes}`;
+ }, [customBreakMinutes, customFocusMinutes, selectedTimerId]);
+
+ const buildSelection = (): CustomEntrySelection => {
+ return {
+ soundId: selectedSoundId,
+ timerId: selectedTimerId,
+ timerLabel,
+ };
+ };
+
+ const resetTab = () => {
+ setActiveTab('theme');
+ };
+
+ return {
+ activeTab,
+ selectedSoundId,
+ selectedTimerId,
+ customFocusMinutes,
+ customBreakMinutes,
+ timerLabel,
+ setActiveTab,
+ setSelectedSoundId,
+ setSelectedTimerId,
+ setCustomFocusMinutes,
+ setCustomBreakMinutes,
+ buildSelection,
+ resetTab,
+ };
+};
diff --git a/src/features/custom-entry-modal/ui/CustomEntryModal.tsx b/src/features/custom-entry-modal/ui/CustomEntryModal.tsx
new file mode 100644
index 0000000..b6771e8
--- /dev/null
+++ b/src/features/custom-entry-modal/ui/CustomEntryModal.tsx
@@ -0,0 +1,159 @@
+'use client';
+
+import { ROOM_THEMES } from '@/entities/room';
+import { SOUND_PRESETS, TIMER_PRESETS } from '@/entities/session';
+import { Button, Chip, Modal, Tabs } from '@/shared/ui';
+import {
+ type CustomEntrySelection,
+ useCustomEntryForm,
+} from '../model/useCustomEntryForm';
+
+interface CustomEntryModalProps {
+ isOpen: boolean;
+ selectedRoomId: string;
+ onSelectRoom: (roomId: string) => void;
+ onClose: () => void;
+ onEnter: (selection: CustomEntrySelection) => void;
+}
+
+export const CustomEntryModal = ({
+ isOpen,
+ selectedRoomId,
+ onSelectRoom,
+ onClose,
+ onEnter,
+}: CustomEntryModalProps) => {
+ const {
+ activeTab,
+ selectedSoundId,
+ selectedTimerId,
+ customFocusMinutes,
+ customBreakMinutes,
+ setActiveTab,
+ setSelectedSoundId,
+ setSelectedTimerId,
+ setCustomFocusMinutes,
+ setCustomBreakMinutes,
+ buildSelection,
+ resetTab,
+ } = useCustomEntryForm();
+
+ const tabOptions = [
+ { value: 'theme', label: '공간' },
+ { value: 'sound', label: '사운드 프리셋' },
+ { value: 'timer', label: '타이머 프리셋' },
+ ];
+
+ const handleClose = () => {
+ resetTab();
+ onClose();
+ };
+
+ const handleEnter = () => {
+ onEnter(buildSelection());
+ resetTab();
+ };
+
+ return (
+
+
+ 닫기
+
+ 이 설정으로 입장
+
+ }
+ >
+
+
setActiveTab(value as 'theme' | 'sound' | 'timer')} />
+
+ {activeTab === 'theme' ? (
+
+ {ROOM_THEMES.map((room) => (
+
onSelectRoom(room.id)}
+ className={`rounded-xl border px-3 py-3 text-left transition-colors ${
+ selectedRoomId === room.id
+ ? 'border-sky-200 bg-sky-300/22 text-sky-50'
+ : 'border-white/16 bg-white/5 text-white/85 hover:bg-white/10'
+ }`}
+ >
+ {room.name}
+ {room.description}
+
+ ))}
+
+ ) : null}
+
+ {activeTab === 'sound' ? (
+
+ {SOUND_PRESETS.map((preset) => (
+ setSelectedSoundId(preset.id)}
+ >
+ {preset.label}
+
+ ))}
+
+ ) : null}
+
+ {activeTab === 'timer' ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/src/features/distraction-dump/index.ts b/src/features/distraction-dump/index.ts
new file mode 100644
index 0000000..34925d0
--- /dev/null
+++ b/src/features/distraction-dump/index.ts
@@ -0,0 +1,4 @@
+export * from './model/useDistractionDump';
+export * from './model/useDistractionNotes';
+export * from './ui/DistractionDumpNotesContent';
+export * from './ui/DistractionDumpPanel';
diff --git a/src/features/distraction-dump/model/useDistractionDump.ts b/src/features/distraction-dump/model/useDistractionDump.ts
new file mode 100644
index 0000000..3d4bda6
--- /dev/null
+++ b/src/features/distraction-dump/model/useDistractionDump.ts
@@ -0,0 +1,38 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { DISTRACTION_DUMP_PLACEHOLDER } from '@/entities/session';
+
+export const useDistractionDump = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [draft, setDraft] = useState('');
+ const [items, setItems] = useState(DISTRACTION_DUMP_PLACEHOLDER);
+
+ const hasDraft = useMemo(() => draft.trim().length > 0, [draft]);
+
+ const toggle = () => {
+ setIsOpen((current) => !current);
+ };
+
+ const saveDraft = () => {
+ if (!hasDraft) {
+ return null;
+ }
+
+ const value = draft.trim();
+ setItems((current) => [value, ...current]);
+ setDraft('');
+
+ return value;
+ };
+
+ return {
+ isOpen,
+ draft,
+ items,
+ hasDraft,
+ setDraft,
+ toggle,
+ saveDraft,
+ };
+};
diff --git a/src/features/distraction-dump/model/useDistractionNotes.ts b/src/features/distraction-dump/model/useDistractionNotes.ts
new file mode 100644
index 0000000..98e1eab
--- /dev/null
+++ b/src/features/distraction-dump/model/useDistractionNotes.ts
@@ -0,0 +1,68 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { DISTRACTION_DUMP_PLACEHOLDER } from '@/entities/session';
+
+interface DistractionNote {
+ id: string;
+ text: string;
+ done: boolean;
+}
+
+const createInitialNotes = (): DistractionNote[] => {
+ return DISTRACTION_DUMP_PLACEHOLDER.slice(0, 5).map((text, index) => ({
+ id: `note-${index + 1}`,
+ text,
+ done: false,
+ }));
+};
+
+export const useDistractionNotes = () => {
+ const [draft, setDraft] = useState('');
+ const [notes, setNotes] = useState(createInitialNotes);
+
+ const canAdd = useMemo(() => draft.trim().length > 0, [draft]);
+
+ const addNote = () => {
+ if (!canAdd) {
+ return null;
+ }
+
+ const value = draft.trim();
+
+ setNotes((current) => [
+ {
+ id: `note-${Date.now()}`,
+ text: value,
+ done: false,
+ },
+ ...current,
+ ]);
+
+ setDraft('');
+
+ return value;
+ };
+
+ const toggleDone = (noteId: string) => {
+ setNotes((current) =>
+ current.map((note) =>
+ note.id === noteId ? { ...note, done: !note.done } : note,
+ ),
+ );
+ };
+
+ const removeNote = (noteId: string) => {
+ setNotes((current) => current.filter((note) => note.id !== noteId));
+ };
+
+ return {
+ draft,
+ notes,
+ canAdd,
+ setDraft,
+ addNote,
+ toggleDone,
+ removeNote,
+ };
+};
diff --git a/src/features/distraction-dump/ui/DistractionDumpNotesContent.tsx b/src/features/distraction-dump/ui/DistractionDumpNotesContent.tsx
new file mode 100644
index 0000000..315bc47
--- /dev/null
+++ b/src/features/distraction-dump/ui/DistractionDumpNotesContent.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { Button } from '@/shared/ui';
+import { cn } from '@/shared/lib/cn';
+import { useDistractionNotes } from '../model/useDistractionNotes';
+
+interface DistractionDumpNotesContentProps {
+ onNoteAdded?: (note: string) => void;
+ onNoteRemoved?: () => void;
+}
+
+export const DistractionDumpNotesContent = ({
+ onNoteAdded,
+ onNoteRemoved,
+}: DistractionDumpNotesContentProps) => {
+ const { draft, notes, canAdd, setDraft, addNote, toggleDone, removeNote } =
+ useDistractionNotes();
+
+ const handleAdd = () => {
+ const note = addNote();
+
+ if (note && onNoteAdded) {
+ onNoteAdded(note);
+ }
+ };
+
+ const handleRemove = (noteId: string) => {
+ removeNote(noteId);
+
+ if (onNoteRemoved) {
+ onNoteRemoved();
+ }
+ };
+
+ return (
+
+
+
+
+ {notes.slice(0, 5).map((note) => (
+
+
+
+ {note.text}
+
+
+ toggleDone(note.id)}
+ className="rounded border border-white/20 px-2 py-0.5 text-[11px] text-white/75 transition hover:bg-white/12"
+ >
+ {note.done ? '해제' : '완료'}
+
+ handleRemove(note.id)}
+ className="rounded border border-white/20 px-2 py-0.5 text-[11px] text-white/75 transition hover:bg-white/12"
+ >
+ 삭제
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/features/distraction-dump/ui/DistractionDumpPanel.tsx b/src/features/distraction-dump/ui/DistractionDumpPanel.tsx
new file mode 100644
index 0000000..1db3909
--- /dev/null
+++ b/src/features/distraction-dump/ui/DistractionDumpPanel.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { Button } from '@/shared/ui';
+import { useDistractionDump } from '../model/useDistractionDump';
+
+interface DistractionDumpPanelProps {
+ onSaved?: (note: string) => void;
+}
+
+export const DistractionDumpPanel = ({
+ onSaved,
+}: DistractionDumpPanelProps) => {
+ const { isOpen, draft, items, hasDraft, setDraft, toggle, saveDraft } =
+ useDistractionDump();
+
+ const handleSave = () => {
+ const value = saveDraft();
+
+ if (value && onSaved) {
+ onSaved(value);
+ }
+ };
+
+ return (
+
+
+ 떠오른 생각 임시 저장
+
+
+ {isOpen ? (
+
+ ) : null}
+
+ );
+};
diff --git a/src/features/profile-menu/index.ts b/src/features/profile-menu/index.ts
new file mode 100644
index 0000000..5285895
--- /dev/null
+++ b/src/features/profile-menu/index.ts
@@ -0,0 +1 @@
+export * from './ui/ProfileMenu';
diff --git a/src/features/profile-menu/ui/ProfileMenu.tsx b/src/features/profile-menu/ui/ProfileMenu.tsx
new file mode 100644
index 0000000..36c1adb
--- /dev/null
+++ b/src/features/profile-menu/ui/ProfileMenu.tsx
@@ -0,0 +1,29 @@
+import type { ViewerProfile } from '@/entities/user';
+import { Dropdown, DropdownItem } from '@/shared/ui';
+
+interface ProfileMenuProps {
+ user: ViewerProfile;
+ onLogout: () => void;
+}
+
+export const ProfileMenu = ({ user, onLogout }: ProfileMenuProps) => {
+ return (
+
+
+ {user.avatarLabel}
+
+ {user.name}
+
+ }
+ >
+ Stats
+ Settings
+
+ Logout
+
+
+ );
+};
diff --git a/src/features/reactions/index.ts b/src/features/reactions/index.ts
new file mode 100644
index 0000000..bf32b30
--- /dev/null
+++ b/src/features/reactions/index.ts
@@ -0,0 +1,2 @@
+export * from './ui/ReactionIconRow';
+export * from './ui/ReactionButtons';
diff --git a/src/features/reactions/ui/ReactionButtons.tsx b/src/features/reactions/ui/ReactionButtons.tsx
new file mode 100644
index 0000000..b649118
--- /dev/null
+++ b/src/features/reactions/ui/ReactionButtons.tsx
@@ -0,0 +1,23 @@
+import type { ReactionOption } from '@/entities/session';
+import { Chip } from '@/shared/ui';
+
+interface ReactionButtonsProps {
+ reactions: ReactionOption[];
+ onReact: (reaction: ReactionOption) => void;
+}
+
+export const ReactionButtons = ({
+ reactions,
+ onReact,
+}: ReactionButtonsProps) => {
+ return (
+
+ {reactions.map((reaction) => (
+ onReact(reaction)}>
+ {reaction.emoji}
+ {reaction.label}
+
+ ))}
+
+ );
+};
diff --git a/src/features/reactions/ui/ReactionIconRow.tsx b/src/features/reactions/ui/ReactionIconRow.tsx
new file mode 100644
index 0000000..91e69ed
--- /dev/null
+++ b/src/features/reactions/ui/ReactionIconRow.tsx
@@ -0,0 +1,31 @@
+import type { ReactionOption } from '@/entities/session';
+import { cn } from '@/shared/lib/cn';
+
+interface ReactionIconRowProps {
+ reactions: ReactionOption[];
+ onReact: (reaction: ReactionOption) => void;
+}
+
+export const ReactionIconRow = ({
+ reactions,
+ onReact,
+}: ReactionIconRowProps) => {
+ return (
+
+ {reactions.map((reaction) => (
+ onReact(reaction)}
+ className={cn(
+ 'inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/20 bg-white/10 text-base transition-colors',
+ 'hover:bg-white/18 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
+ )}
+ >
+ {reaction.emoji}
+
+ ))}
+
+ );
+};
diff --git a/src/features/restart-30s/index.ts b/src/features/restart-30s/index.ts
new file mode 100644
index 0000000..2437d17
--- /dev/null
+++ b/src/features/restart-30s/index.ts
@@ -0,0 +1 @@
+export * from './ui/Restart30sAction';
diff --git a/src/features/restart-30s/model/useRestart30s.ts b/src/features/restart-30s/model/useRestart30s.ts
new file mode 100644
index 0000000..a51e030
--- /dev/null
+++ b/src/features/restart-30s/model/useRestart30s.ts
@@ -0,0 +1,18 @@
+'use client';
+
+import { useToast } from '@/shared/ui';
+
+export const useRestart30s = () => {
+ const { pushToast } = useToast();
+
+ const triggerRestart = () => {
+ pushToast({
+ title: '30초 리스타트(더미)',
+ description: '실제 리스타트 동작은 아직 연결되지 않았어요.',
+ });
+ };
+
+ return {
+ triggerRestart,
+ };
+};
diff --git a/src/features/restart-30s/ui/Restart30sAction.tsx b/src/features/restart-30s/ui/Restart30sAction.tsx
new file mode 100644
index 0000000..03b0707
--- /dev/null
+++ b/src/features/restart-30s/ui/Restart30sAction.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { cn } from '@/shared/lib/cn';
+import { useRestart30s } from '../model/useRestart30s';
+
+interface Restart30sActionProps {
+ className?: string;
+}
+
+export const Restart30sAction = ({ className }: Restart30sActionProps) => {
+ const { triggerRestart } = useRestart30s();
+
+ return (
+
+
+ ↻
+
+ 다시 시작
+
+ 30초
+
+
+ );
+};
diff --git a/src/features/room-select/index.ts b/src/features/room-select/index.ts
new file mode 100644
index 0000000..8560eaa
--- /dev/null
+++ b/src/features/room-select/index.ts
@@ -0,0 +1,2 @@
+export * from './model/useRoomSelection';
+export * from './ui/RoomPreviewCard';
diff --git a/src/features/room-select/model/useRoomSelection.ts b/src/features/room-select/model/useRoomSelection.ts
new file mode 100644
index 0000000..6209546
--- /dev/null
+++ b/src/features/room-select/model/useRoomSelection.ts
@@ -0,0 +1,20 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { getRoomById, ROOM_THEMES } from '@/entities/room';
+
+export const useRoomSelection = (initialRoomId?: string) => {
+ const [selectedRoomId, setSelectedRoomId] = useState(
+ initialRoomId ?? ROOM_THEMES[0].id,
+ );
+
+ const selectedRoom = useMemo(() => {
+ return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
+ }, [selectedRoomId]);
+
+ return {
+ selectedRoomId,
+ selectedRoom,
+ selectRoom: setSelectedRoomId,
+ };
+};
diff --git a/src/features/room-select/ui/RoomPreviewCard.tsx b/src/features/room-select/ui/RoomPreviewCard.tsx
new file mode 100644
index 0000000..94552f4
--- /dev/null
+++ b/src/features/room-select/ui/RoomPreviewCard.tsx
@@ -0,0 +1,62 @@
+import type { RoomTheme } from '@/entities/room';
+import { getRoomBackgroundStyle } from '@/entities/room';
+import { Chip } from '@/shared/ui';
+import { cn } from '@/shared/lib/cn';
+
+interface RoomPreviewCardProps {
+ room: RoomTheme;
+ selected: boolean;
+ onSelect: (roomId: string) => void;
+}
+
+export const RoomPreviewCard = ({
+ room,
+ selected,
+ onSelect,
+}: RoomPreviewCardProps) => {
+ return (
+ onSelect(room.id)}
+ className={cn(
+ 'group relative overflow-hidden rounded-2xl border p-4 text-left transition-all duration-250 motion-reduce:transition-none',
+ selected
+ ? 'border-sky-200/85 shadow-[0_0_0_1px_rgba(186,230,253,0.9)]'
+ : 'border-white/18 hover:border-white/35',
+ )}
+ >
+
+
+
+
+
+
+
{room.name}
+
{room.description}
+
+
+
+ {room.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+ 추천 사운드: {room.recommendedSound}
+
+
+
+ 추천 시간 · {room.recommendedTime}
+
+
+ 지금 분위기 · {room.vibeLabel}
+
+
+
+
+
+ );
+};
diff --git a/src/features/space/components/FocusRoomScreen.tsx b/src/features/space/components/FocusRoomScreen.tsx
new file mode 100644
index 0000000..4f5427a
--- /dev/null
+++ b/src/features/space/components/FocusRoomScreen.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useFocusRoomViewModel } from "../hooks/useFocusRoomViewModel";
+
+export const FocusRoomScreen = () => {
+ const { goal, leaveRoom } = useFocusRoomViewModel();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Distraction Dump
+
+
+
+
+
+
+
+ 설정 패널
+
+
+ 배경 선택
+ 사운드 선택
+ 볼륨 슬라이더
+ 타이머(옵션)
+
+
+
+
+
+
+
+ );
+};
+
diff --git a/src/features/space/components/RoomEntryForm.tsx b/src/features/space/components/RoomEntryForm.tsx
new file mode 100644
index 0000000..2252c0f
--- /dev/null
+++ b/src/features/space/components/RoomEntryForm.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useRoomEntry } from "../hooks/useRoomEntry";
+
+export const RoomEntryForm = () => {
+ const { focusGoal, setFocusGoal, canEnterRoom, handleSubmit } = useRoomEntry();
+
+ return (
+
+ );
+};
+
diff --git a/src/features/space/hooks/useDashboardViewModel.ts b/src/features/space/hooks/useDashboardViewModel.ts
new file mode 100644
index 0000000..15327b5
--- /dev/null
+++ b/src/features/space/hooks/useDashboardViewModel.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useUserProfile } from "@/features/user";
+import { useAuthStore } from "@/store/useAuthStore";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+export const useDashboardViewModel = () => {
+ const router = useRouter();
+ const { logout, isAuthenticated } = useAuthStore();
+ const { user, isLoading } = useUserProfile();
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ router.push("/login");
+ }
+ }, [isAuthenticated, router]);
+
+ const handleLogout = () => {
+ logout();
+ router.push("/login");
+ };
+
+ return {
+ user,
+ isLoading,
+ handleLogout,
+ };
+};
+
diff --git a/src/features/space/hooks/useFocusRoomViewModel.ts b/src/features/space/hooks/useFocusRoomViewModel.ts
new file mode 100644
index 0000000..4a0e58b
--- /dev/null
+++ b/src/features/space/hooks/useFocusRoomViewModel.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useAuthStore } from "@/store/useAuthStore";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo } from "react";
+
+export const useFocusRoomViewModel = () => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+
+ useEffect(() => {
+ if (!isAuthenticated) {
+ router.push("/login");
+ }
+ }, [isAuthenticated, router]);
+
+ const goal = useMemo(() => {
+ const fromQuery = searchParams.get("goal")?.trim();
+ return fromQuery?.length ? fromQuery : "오늘의 목표를 설정해 주세요";
+ }, [searchParams]);
+
+ const leaveRoom = () => {
+ router.push("/app");
+ };
+
+ return {
+ goal,
+ leaveRoom,
+ };
+};
diff --git a/src/features/space/hooks/useRoomEntry.ts b/src/features/space/hooks/useRoomEntry.ts
new file mode 100644
index 0000000..4a88149
--- /dev/null
+++ b/src/features/space/hooks/useRoomEntry.ts
@@ -0,0 +1,33 @@
+"use client";
+
+import { FormEvent, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+export const useRoomEntry = () => {
+ const router = useRouter();
+ const [focusGoal, setFocusGoal] = useState("");
+
+ const normalizedGoal = useMemo(() => focusGoal.trim(), [focusGoal]);
+ const canEnterRoom = normalizedGoal.length > 0;
+
+ const enterRoom = () => {
+ if (!canEnterRoom) {
+ return;
+ }
+
+ const query = new URLSearchParams({ goal: normalizedGoal }).toString();
+ router.push(`/space?${query}`);
+ };
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ enterRoom();
+ };
+
+ return {
+ focusGoal,
+ setFocusGoal,
+ canEnterRoom,
+ handleSubmit,
+ };
+};
diff --git a/src/features/space/index.ts b/src/features/space/index.ts
new file mode 100644
index 0000000..8a1c86d
--- /dev/null
+++ b/src/features/space/index.ts
@@ -0,0 +1,4 @@
+export * from "./components/FocusRoomScreen";
+export * from "./components/RoomEntryForm";
+export * from "./hooks/useDashboardViewModel";
+
diff --git a/src/shared/config/settingsOptions.ts b/src/shared/config/settingsOptions.ts
new file mode 100644
index 0000000..cdd68cf
--- /dev/null
+++ b/src/shared/config/settingsOptions.ts
@@ -0,0 +1,7 @@
+export const NOTIFICATION_INTENSITY_OPTIONS = ['조용함', '기본', '강함'] as const;
+
+export const DEFAULT_PRESET_OPTIONS = [
+ { id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
+ { id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
+ { id: 'gentle', label: 'Gentle 25/5 + Silent' },
+] as const;
diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts
new file mode 100644
index 0000000..86ef08a
--- /dev/null
+++ b/src/shared/lib/cn.ts
@@ -0,0 +1,3 @@
+export const cn = (...classes: Array) => {
+ return classes.filter(Boolean).join(' ');
+};
diff --git a/src/shared/lib/useReducedMotion.ts b/src/shared/lib/useReducedMotion.ts
new file mode 100644
index 0000000..0562627
--- /dev/null
+++ b/src/shared/lib/useReducedMotion.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+const QUERY = '(prefers-reduced-motion: reduce)';
+
+export const useReducedMotion = () => {
+ const [reduced, setReduced] = useState(false);
+
+ useEffect(() => {
+ const media = window.matchMedia(QUERY);
+ const update = () => setReduced(media.matches);
+
+ update();
+ media.addEventListener('change', update);
+
+ return () => {
+ media.removeEventListener('change', update);
+ };
+ }, []);
+
+ return reduced;
+};
diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx
index 8bed4c5..3edd203 100644
--- a/src/shared/ui/Button.tsx
+++ b/src/shared/ui/Button.tsx
@@ -1,65 +1,65 @@
-import React, { ButtonHTMLAttributes } from 'react';
-import Link, { LinkProps } from 'next/link';
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+import Link from 'next/link';
+import { cn } from '@/shared/lib/cn';
-// 1. 버튼 Variant(스타일) 및 Size 타입 정의
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg' | 'full';
-// 2. 공통 Props 인터페이스 정의 (HTML 버튼 속성 + Link 속성 혼합)
-export interface ButtonProps extends React.ButtonHTMLAttributes {
+interface CommonButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
- href?: string; // href가 주어지면 Next.js Link 컴포넌트로 렌더링
className?: string;
- children: React.ReactNode;
+ children: ReactNode;
}
-/**
- * VibeRoom 공통 버튼 컴포넌트
- * (3-Color System 엄격 적용: #304d6d, #63adf2, #a7cced)
- */
-export const Button = React.forwardRef(
- (
- { variant = 'primary', size = 'md', href, className = '', children, ...props },
- ref
- ) => {
- // 공통 베이스 스타일
- const baseStyle = "inline-flex items-center justify-center font-bold rounded-xl transition-all duration-200";
+type LinkButtonProps = CommonButtonProps & {
+ href: string;
+};
- // Variant별 테마 (VibeRoom 3-Color System)
- const variants: Record = {
- primary: "bg-brand-primary text-white hover:bg-brand-primary/90 shadow-sm",
- secondary: "bg-slate-50 text-brand-dark hover:bg-slate-100",
- outline: "bg-white/50 text-brand-dark border border-brand-dark/20 hover:bg-white shadow-sm",
- ghost: "bg-transparent text-brand-dark/80 hover:text-brand-primary",
- };
+type NativeButtonProps = CommonButtonProps &
+ ButtonHTMLAttributes & {
+ href?: undefined;
+ };
- // 크기별 테마
- const sizes: Record = {
- sm: "px-4 py-2 text-sm",
- md: "px-5 py-2.5 text-base",
- lg: "px-8 py-4 text-lg",
- full: "w-full py-3 px-4 text-base",
- };
+export type ButtonProps = LinkButtonProps | NativeButtonProps;
- const combinedClassName = `${baseStyle} ${variants[variant]} ${sizes[size]} ${className}`;
+const baseStyle =
+ 'inline-flex items-center justify-center rounded-xl font-semibold transition-all duration-200 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 disabled:cursor-not-allowed disabled:opacity-55';
- // href 속성이 있으면 Next.js Link로 렌더링
- if (href) {
- return (
- }>
- {children}
-
- );
- }
+const variants: Record = {
+ primary: 'bg-brand-primary text-white shadow-sm hover:bg-brand-primary/90',
+ secondary: 'bg-slate-100 text-brand-dark hover:bg-slate-200',
+ outline: 'bg-white/80 text-brand-dark ring-1 ring-slate-300 hover:bg-white',
+ ghost: 'bg-transparent text-brand-dark/80 hover:bg-brand-dark/8 hover:text-brand-dark',
+};
- // 기본은 HTML Button
+const sizes: Record = {
+ sm: 'h-9 px-3 text-sm',
+ md: 'h-10 px-4 text-sm',
+ lg: 'h-12 px-5 text-base',
+ full: 'h-11 w-full px-4 text-sm',
+};
+
+export const Button = ({
+ variant = 'primary',
+ size = 'md',
+ className,
+ children,
+ ...props
+}: ButtonProps) => {
+ const style = cn(baseStyle, variants[variant], sizes[size], className);
+
+ if ('href' in props && props.href) {
return (
- } {...props}>
+
{children}
-
+
);
}
-);
-Button.displayName = 'Button';
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/shared/ui/Chip.tsx b/src/shared/ui/Chip.tsx
new file mode 100644
index 0000000..6ec3816
--- /dev/null
+++ b/src/shared/ui/Chip.tsx
@@ -0,0 +1,42 @@
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+import { cn } from '@/shared/lib/cn';
+
+type ChipTone = 'neutral' | 'accent' | 'muted';
+
+interface ChipProps extends ButtonHTMLAttributes {
+ active?: boolean;
+ tone?: ChipTone;
+ children: ReactNode;
+}
+
+const toneStyles: Record = {
+ neutral:
+ 'bg-white/10 text-white/90 ring-1 ring-white/20 hover:bg-white/16',
+ accent:
+ 'bg-sky-300/25 text-sky-100 ring-1 ring-sky-200/55 hover:bg-sky-300/35',
+ muted:
+ 'bg-slate-300/25 text-slate-100 ring-1 ring-slate-200/40 hover:bg-slate-300/32',
+};
+
+export const Chip = ({
+ active = false,
+ tone = 'neutral',
+ className,
+ children,
+ ...props
+}: ChipProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/shared/ui/Dropdown.tsx b/src/shared/ui/Dropdown.tsx
new file mode 100644
index 0000000..e856445
--- /dev/null
+++ b/src/shared/ui/Dropdown.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import Link from 'next/link';
+import { cn } from '@/shared/lib/cn';
+
+interface DropdownProps {
+ trigger: ReactNode;
+ children: ReactNode;
+ align?: 'left' | 'right';
+}
+
+interface DropdownItemProps {
+ children: ReactNode;
+ href?: string;
+ onClick?: () => void;
+ danger?: boolean;
+}
+
+export const Dropdown = ({ trigger, children, align = 'right' }: DropdownProps) => {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ const onPointerDown = (event: MouseEvent) => {
+ if (!rootRef.current?.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+
+ const onEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', onPointerDown);
+ document.addEventListener('keydown', onEscape);
+
+ return () => {
+ document.removeEventListener('mousedown', onPointerDown);
+ document.removeEventListener('keydown', onEscape);
+ };
+ }, [open]);
+
+ return (
+
+
setOpen((current) => !current)}
+ className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
+ >
+ {trigger}
+
+
+ {open ? (
+
setOpen(false)}
+ className={cn(
+ 'absolute top-[calc(100%+0.5rem)] z-30 min-w-44 rounded-xl border border-white/15 bg-slate-950/95 p-1 shadow-2xl shadow-slate-950/70',
+ align === 'right' ? 'right-0' : 'left-0',
+ )}
+ >
+ {children}
+
+ ) : null}
+
+ );
+};
+
+export const DropdownItem = ({
+ children,
+ href,
+ onClick,
+ danger = false,
+}: DropdownItemProps) => {
+ const className = cn(
+ 'flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors',
+ danger
+ ? 'text-rose-200 hover:bg-rose-300/20'
+ : 'text-white/90 hover:bg-white/10 hover:text-white',
+ );
+
+ if (href) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/shared/ui/GlassCard.tsx b/src/shared/ui/GlassCard.tsx
new file mode 100644
index 0000000..0b7f28f
--- /dev/null
+++ b/src/shared/ui/GlassCard.tsx
@@ -0,0 +1,26 @@
+import type { HTMLAttributes } from 'react';
+import { cn } from '@/shared/lib/cn';
+
+interface GlassCardProps extends HTMLAttributes {
+ elevated?: boolean;
+}
+
+export const GlassCard = ({
+ elevated = false,
+ className,
+ children,
+ ...props
+}: GlassCardProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/shared/ui/Modal.tsx b/src/shared/ui/Modal.tsx
new file mode 100644
index 0000000..c0f4641
--- /dev/null
+++ b/src/shared/ui/Modal.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { useEffect } from 'react';
+import { cn } from '@/shared/lib/cn';
+import { useReducedMotion } from '@/shared/lib/useReducedMotion';
+
+interface ModalProps {
+ isOpen: boolean;
+ title: string;
+ description?: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+}
+
+export const Modal = ({
+ isOpen,
+ title,
+ description,
+ onClose,
+ children,
+ footer,
+}: ModalProps) => {
+ const reducedMotion = useReducedMotion();
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', onKeyDown);
+
+ return () => {
+ document.removeEventListener('keydown', onKeyDown);
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+ 닫기
+
+
+
+
+
{children}
+
+ {footer ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx
new file mode 100644
index 0000000..a2ef9e4
--- /dev/null
+++ b/src/shared/ui/Tabs.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { cn } from '@/shared/lib/cn';
+
+export interface TabOption {
+ value: string;
+ label: string;
+}
+
+interface TabsProps {
+ value: string;
+ options: TabOption[];
+ onChange: (value: string) => void;
+}
+
+export const Tabs = ({ value, options, onChange }: TabsProps) => {
+ return (
+
+ {options.map((option) => (
+ onChange(option.value)}
+ className={cn(
+ 'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 motion-reduce:transition-none',
+ option.value === value
+ ? 'bg-sky-300/22 text-sky-100'
+ : 'text-white/65 hover:bg-white/8 hover:text-white/90',
+ )}
+ >
+ {option.label}
+
+ ))}
+
+ );
+};
diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx
new file mode 100644
index 0000000..de85d8b
--- /dev/null
+++ b/src/shared/ui/Toast.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { createContext, useCallback, useContext, useMemo, useState } from 'react';
+import { cn } from '@/shared/lib/cn';
+
+interface ToastPayload {
+ title: string;
+ description?: string;
+}
+
+interface ToastItem extends ToastPayload {
+ id: number;
+}
+
+interface ToastContextValue {
+ pushToast: (payload: ToastPayload) => void;
+}
+
+const ToastContext = createContext(null);
+
+export const ToastProvider = ({ children }: { children: ReactNode }) => {
+ const [toasts, setToasts] = useState([]);
+
+ const pushToast = useCallback((payload: ToastPayload) => {
+ const id = Date.now() + Math.floor(Math.random() * 10000);
+
+ setToasts((current) => [...current, { id, ...payload }]);
+
+ window.setTimeout(() => {
+ setToasts((current) => current.filter((toast) => toast.id !== id));
+ }, 2400);
+ }, []);
+
+ const value = useMemo(() => ({ pushToast }), [pushToast]);
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+
+
{toast.title}
+ {toast.description ? (
+
{toast.description}
+ ) : null}
+
+ ))}
+
+
+ );
+};
+
+export const useToast = () => {
+ const context = useContext(ToastContext);
+
+ if (!context) {
+ throw new Error('useToast must be used within ToastProvider');
+ }
+
+ return context;
+};
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
new file mode 100644
index 0000000..27173b6
--- /dev/null
+++ b/src/shared/ui/index.ts
@@ -0,0 +1,7 @@
+export * from './Button';
+export * from './Chip';
+export * from './Dropdown';
+export * from './GlassCard';
+export * from './Modal';
+export * from './Tabs';
+export * from './Toast';
diff --git a/src/widgets/app-hub/index.ts b/src/widgets/app-hub/index.ts
new file mode 100644
index 0000000..d7ecf61
--- /dev/null
+++ b/src/widgets/app-hub/index.ts
@@ -0,0 +1 @@
+export * from './ui/AppHubWidget';
diff --git a/src/widgets/app-hub/ui/AppHubWidget.tsx b/src/widgets/app-hub/ui/AppHubWidget.tsx
new file mode 100644
index 0000000..c06598a
--- /dev/null
+++ b/src/widgets/app-hub/ui/AppHubWidget.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { getRoomBackgroundStyle, ROOM_THEMES } from '@/entities/room';
+import {
+ GOAL_CHIPS,
+ SOUND_PRESETS,
+ TIMER_PRESETS,
+ TODAY_ONE_LINER,
+ type GoalChip,
+} from '@/entities/session';
+import { MOCK_VIEWER } from '@/entities/user';
+import { type CustomEntrySelection } from '@/features/custom-entry-modal';
+import { useRoomSelection } from '@/features/room-select';
+import { useToast } from '@/shared/ui';
+import { AppTopBar } from '@/widgets/app-top-bar/ui/AppTopBar';
+import { CustomEntryWidget } from '@/widgets/custom-entry-widget/ui/CustomEntryWidget';
+import { RoomsGalleryWidget } from '@/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget';
+import { StartRitualWidget } from '@/widgets/start-ritual-widget/ui/StartRitualWidget';
+
+const buildSpaceQuery = (
+ roomId: string,
+ goalInput: string,
+ soundPresetId: string,
+ timerLabel: string,
+) => {
+ const params = new URLSearchParams({
+ room: roomId,
+ sound: soundPresetId,
+ timer: timerLabel,
+ });
+
+ const normalizedGoal = goalInput.trim();
+
+ if (normalizedGoal) {
+ params.set('goal', normalizedGoal);
+ }
+
+ return params.toString();
+};
+
+export const AppHubWidget = () => {
+ const router = useRouter();
+ const { pushToast } = useToast();
+ const { selectedRoom, selectedRoomId, selectRoom } = useRoomSelection();
+
+ const [goalInput, setGoalInput] = useState('');
+ const [selectedGoalId, setSelectedGoalId] = useState(null);
+ const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
+
+ const enterSpace = (soundPresetId: string, timerLabel: string) => {
+ const query = buildSpaceQuery(
+ selectedRoomId,
+ goalInput,
+ soundPresetId,
+ timerLabel,
+ );
+
+ router.push(`/space?${query}`);
+ };
+
+ const handleGoalChipSelect = (chip: GoalChip) => {
+ setSelectedGoalId(chip.id);
+ setGoalInput(chip.label);
+ };
+
+ const handleGoalInputChange = (value: string) => {
+ setGoalInput(value);
+
+ if (selectedGoalId && value.trim() === '') {
+ setSelectedGoalId(null);
+ }
+ };
+
+ const handleQuickEnter = () => {
+ enterSpace(SOUND_PRESETS[0].id, TIMER_PRESETS[0].label);
+ };
+
+ const handleCustomEnter = (selection: CustomEntrySelection) => {
+ setCustomEntryOpen(false);
+ enterSpace(selection.soundId, selection.timerLabel);
+ };
+
+ const handleLogout = () => {
+ pushToast({
+ title: '로그아웃은 목업에서만 동작해요',
+ description: '실제 인증 로직은 연결하지 않았습니다.',
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ setCustomEntryOpen(true)}
+ />
+
+
+
+
+
+
+
setCustomEntryOpen(false)}
+ onEnter={handleCustomEnter}
+ />
+
+ );
+};
diff --git a/src/widgets/app-top-bar/index.ts b/src/widgets/app-top-bar/index.ts
new file mode 100644
index 0000000..70b9c53
--- /dev/null
+++ b/src/widgets/app-top-bar/index.ts
@@ -0,0 +1 @@
+export * from './ui/AppTopBar';
diff --git a/src/widgets/app-top-bar/ui/AppTopBar.tsx b/src/widgets/app-top-bar/ui/AppTopBar.tsx
new file mode 100644
index 0000000..287c3b1
--- /dev/null
+++ b/src/widgets/app-top-bar/ui/AppTopBar.tsx
@@ -0,0 +1,27 @@
+import type { ViewerProfile } from '@/entities/user';
+import { ProfileMenu } from '@/features/profile-menu';
+
+interface AppTopBarProps {
+ user: ViewerProfile;
+ oneLiner: string;
+ onLogout: () => void;
+}
+
+export const AppTopBar = ({ user, oneLiner, onLogout }: AppTopBarProps) => {
+ return (
+
+ );
+};
diff --git a/src/widgets/custom-entry-widget/index.ts b/src/widgets/custom-entry-widget/index.ts
new file mode 100644
index 0000000..d7bc204
--- /dev/null
+++ b/src/widgets/custom-entry-widget/index.ts
@@ -0,0 +1 @@
+export * from './ui/CustomEntryWidget';
diff --git a/src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx b/src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx
new file mode 100644
index 0000000..204681b
--- /dev/null
+++ b/src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx
@@ -0,0 +1,30 @@
+import {
+ type CustomEntrySelection,
+ CustomEntryModal,
+} from '@/features/custom-entry-modal';
+
+interface CustomEntryWidgetProps {
+ isOpen: boolean;
+ selectedRoomId: string;
+ onSelectRoom: (roomId: string) => void;
+ onClose: () => void;
+ onEnter: (selection: CustomEntrySelection) => void;
+}
+
+export const CustomEntryWidget = ({
+ isOpen,
+ selectedRoomId,
+ onSelectRoom,
+ onClose,
+ onEnter,
+}: CustomEntryWidgetProps) => {
+ return (
+
+ );
+};
diff --git a/src/widgets/notes-sheet/index.ts b/src/widgets/notes-sheet/index.ts
new file mode 100644
index 0000000..3363de8
--- /dev/null
+++ b/src/widgets/notes-sheet/index.ts
@@ -0,0 +1 @@
+export * from './ui/NotesSheetWidget';
diff --git a/src/widgets/notes-sheet/ui/NotesSheetWidget.tsx b/src/widgets/notes-sheet/ui/NotesSheetWidget.tsx
new file mode 100644
index 0000000..5372ddb
--- /dev/null
+++ b/src/widgets/notes-sheet/ui/NotesSheetWidget.tsx
@@ -0,0 +1,37 @@
+import { DistractionDumpNotesContent } from '@/features/distraction-dump';
+
+interface NotesSheetWidgetProps {
+ onClose: () => void;
+ onNoteAdded?: (note: string) => void;
+ onNoteRemoved?: () => void;
+}
+
+export const NotesSheetWidget = ({
+ onClose,
+ onNoteAdded,
+ onNoteRemoved,
+}: NotesSheetWidgetProps) => {
+ return (
+
+ );
+};
diff --git a/src/widgets/quick-sheet/index.ts b/src/widgets/quick-sheet/index.ts
new file mode 100644
index 0000000..5053de2
--- /dev/null
+++ b/src/widgets/quick-sheet/index.ts
@@ -0,0 +1 @@
+export * from './ui/QuickSheetWidget';
diff --git a/src/widgets/quick-sheet/ui/QuickSheetWidget.tsx b/src/widgets/quick-sheet/ui/QuickSheetWidget.tsx
new file mode 100644
index 0000000..3637665
--- /dev/null
+++ b/src/widgets/quick-sheet/ui/QuickSheetWidget.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useState } from 'react';
+
+interface QuickSheetWidgetProps {
+ onClose: () => void;
+}
+
+export const QuickSheetWidget = ({ onClose }: QuickSheetWidgetProps) => {
+ const [immersionMode, setImmersionMode] = useState(false);
+ const [minimalNotice, setMinimalNotice] = useState(false);
+
+ return (
+
+
+
+
+
+
setImmersionMode((current) => !current)}
+ className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
+ >
+ 몰입 모드
+ {immersionMode ? 'ON' : 'OFF'}
+
+
+
setMinimalNotice((current) => !current)}
+ className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
+ >
+ 알림 최소화
+ {minimalNotice ? 'ON' : 'OFF'}
+
+
+
+ 빠른 옵션 UI 목업입니다. 실제 동작은 연결하지 않았습니다.
+
+
+
+
+ );
+};
diff --git a/src/widgets/room-sheet/index.ts b/src/widgets/room-sheet/index.ts
new file mode 100644
index 0000000..3607ea3
--- /dev/null
+++ b/src/widgets/room-sheet/index.ts
@@ -0,0 +1 @@
+export * from './ui/RoomSheetWidget';
diff --git a/src/widgets/room-sheet/ui/RoomSheetWidget.tsx b/src/widgets/room-sheet/ui/RoomSheetWidget.tsx
new file mode 100644
index 0000000..3dd3f48
--- /dev/null
+++ b/src/widgets/room-sheet/ui/RoomSheetWidget.tsx
@@ -0,0 +1,98 @@
+import type { RoomPresence } from '@/entities/room';
+import type { CheckInPhrase, ReactionOption } from '@/entities/session';
+import { CompactCheckInChips } from '@/features/check-in';
+import { ReactionIconRow } from '@/features/reactions';
+import { Chip } from '@/shared/ui';
+
+interface RoomSheetWidgetProps {
+ roomName: string;
+ activeMembers: number;
+ presence: RoomPresence;
+ checkInPhrases: CheckInPhrase[];
+ reactions: ReactionOption[];
+ lastCheckIn: string | null;
+ onClose: () => void;
+ onCheckIn: (message: string) => void;
+ onReaction: (reaction: ReactionOption) => void;
+}
+
+export const RoomSheetWidget = ({
+ roomName,
+ activeMembers,
+ presence,
+ checkInPhrases,
+ reactions,
+ lastCheckIn,
+ onClose,
+ onCheckIn,
+ onReaction,
+}: RoomSheetWidgetProps) => {
+ return (
+
+
+
+
+
+
+ 마지막 체크인:{' '}
+
+ {lastCheckIn ?? '아직 체크인이 없습니다'}
+
+
+
+ 채팅/사용자 리스트는 표시하지 않는 집중형 존재감 시트입니다.
+
+
+
+
+ );
+};
diff --git a/src/widgets/rooms-gallery-widget/index.ts b/src/widgets/rooms-gallery-widget/index.ts
new file mode 100644
index 0000000..925fb15
--- /dev/null
+++ b/src/widgets/rooms-gallery-widget/index.ts
@@ -0,0 +1 @@
+export * from './ui/RoomsGalleryWidget';
diff --git a/src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx b/src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx
new file mode 100644
index 0000000..0a2703b
--- /dev/null
+++ b/src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx
@@ -0,0 +1,35 @@
+import type { RoomTheme } from '@/entities/room';
+import { RoomPreviewCard } from '@/features/room-select';
+import { GlassCard } from '@/shared/ui';
+
+interface RoomsGalleryWidgetProps {
+ rooms: RoomTheme[];
+ selectedRoomId: string;
+ onRoomSelect: (roomId: string) => void;
+}
+
+export const RoomsGalleryWidget = ({
+ rooms,
+ selectedRoomId,
+ onRoomSelect,
+}: RoomsGalleryWidgetProps) => {
+ return (
+
+
+
오늘의 공간
+
감정에 맞는 분위기 하나만 고르면 충분해요.
+
+
+
+ {rooms.map((room) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/widgets/settings-panel/index.ts b/src/widgets/settings-panel/index.ts
new file mode 100644
index 0000000..32027cc
--- /dev/null
+++ b/src/widgets/settings-panel/index.ts
@@ -0,0 +1 @@
+export * from './ui/SettingsPanelWidget';
diff --git a/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx
new file mode 100644
index 0000000..1925e2f
--- /dev/null
+++ b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import Link from 'next/link';
+import { useState } from 'react';
+import {
+ DEFAULT_PRESET_OPTIONS,
+ NOTIFICATION_INTENSITY_OPTIONS,
+} from '@/shared/config/settingsOptions';
+import { cn } from '@/shared/lib/cn';
+
+export const SettingsPanelWidget = () => {
+ const [reduceMotion, setReduceMotion] = useState(false);
+ const [notificationIntensity, setNotificationIntensity] =
+ useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본');
+ const [defaultPresetId, setDefaultPresetId] = useState<
+ (typeof DEFAULT_PRESET_OPTIONS)[number]['id']
+ >(DEFAULT_PRESET_OPTIONS[0].id);
+
+ return (
+
+
+
+
+
+
+
+
+
Reduce Motion
+
+ 전환 애니메이션을 최소화합니다. (UI 토글 목업)
+
+
+
setReduceMotion((current) => !current)}
+ className={cn(
+ 'inline-flex w-16 items-center rounded-full border px-1 py-1 transition-colors',
+ reduceMotion
+ ? 'border-sky-200/70 bg-sky-300/28'
+ : 'border-white/30 bg-white/10',
+ )}
+ >
+
+
+
+
+
+
+ 알림 강도
+ 집중 시작/종료 신호의 존재감을 선택합니다.
+
+ {NOTIFICATION_INTENSITY_OPTIONS.map((option) => (
+ setNotificationIntensity(option)}
+ className={cn(
+ 'rounded-full border px-3 py-1.5 text-xs transition-colors',
+ notificationIntensity === option
+ ? 'border-sky-200/80 bg-sky-300/25 text-sky-50'
+ : 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
+ )}
+ >
+ {option}
+
+ ))}
+
+
+
+
+ 기본 프리셋
+ 입장 시 자동 선택될 추천 세트를 고릅니다.
+
+ {DEFAULT_PRESET_OPTIONS.map((preset) => (
+ setDefaultPresetId(preset.id)}
+ className={cn(
+ 'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
+ defaultPresetId === preset.id
+ ? 'border-sky-200/75 bg-sky-300/20 text-sky-50'
+ : 'border-white/18 bg-white/5 text-white/85 hover:bg-white/10',
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/src/widgets/space-shell/index.ts b/src/widgets/space-shell/index.ts
new file mode 100644
index 0000000..fbfaba7
--- /dev/null
+++ b/src/widgets/space-shell/index.ts
@@ -0,0 +1 @@
+export * from './ui/SpaceSkeletonWidget';
diff --git a/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx
new file mode 100644
index 0000000..f036534
--- /dev/null
+++ b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import { getRoomBackgroundStyle, getRoomById, ROOM_THEMES } from '@/entities/room';
+import { SOUND_PRESETS } from '@/entities/session';
+import { cn } from '@/shared/lib/cn';
+import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
+import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
+
+export const SpaceSkeletonWidget = () => {
+ const searchParams = useSearchParams();
+
+ const roomId = searchParams.get('room') ?? ROOM_THEMES[0].id;
+ const goal = searchParams.get('goal') ?? '오늘은 한 조각만 집중해요';
+ const timerLabel = searchParams.get('timer') ?? '25/5';
+ const soundFromQuery = searchParams.get('sound');
+
+ const room = useMemo(() => getRoomById(roomId) ?? ROOM_THEMES[0], [roomId]);
+
+ const defaultSoundId =
+ SOUND_PRESETS.find((preset) => preset.id === soundFromQuery)?.id ??
+ SOUND_PRESETS[0].id;
+
+ const [selectedSoundId, setSelectedSoundId] = useState(defaultSoundId);
+
+ useEffect(() => {
+ setSelectedSoundId(defaultSoundId);
+ }, [defaultSoundId]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
Current Room
+
{room.name}
+
현재 {room.activeMembers}명 함께 집중 중
+
+
+
+
+
+
+ {SOUND_PRESETS.map((preset) => (
+ setSelectedSoundId(preset.id)}
+ className={cn(
+ 'rounded-full border px-3 py-1.5 text-xs transition-colors',
+ selectedSoundId === preset.id
+ ? 'border-sky-200/75 bg-sky-300/25 text-sky-50'
+ : 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
+ )}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/widgets/space-timer-hud/index.ts b/src/widgets/space-timer-hud/index.ts
new file mode 100644
index 0000000..baf6f7c
--- /dev/null
+++ b/src/widgets/space-timer-hud/index.ts
@@ -0,0 +1 @@
+export * from './ui/SpaceTimerHudWidget';
diff --git a/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx
new file mode 100644
index 0000000..c895336
--- /dev/null
+++ b/src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx
@@ -0,0 +1,56 @@
+import { cn } from '@/shared/lib/cn';
+import { Restart30sAction } from '@/features/restart-30s';
+
+interface SpaceTimerHudWidgetProps {
+ timerLabel: string;
+ goal: string;
+ className?: string;
+}
+
+const HUD_ACTIONS = [
+ { id: 'start', label: '시작', icon: '▶' },
+ { id: 'pause', label: '일시정지', icon: '⏸' },
+ { id: 'reset', label: '리셋', icon: '↺' },
+] as const;
+
+export const SpaceTimerHudWidget = ({
+ timerLabel,
+ goal,
+ className,
+}: SpaceTimerHudWidgetProps) => {
+ return (
+
+
+
+
+
+
+ Focus
+
+ 25:00
+ {timerLabel}
+
+
목표: {goal}
+
+
+
+
+ {HUD_ACTIONS.map((action) => (
+
+ {action.icon}
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/src/widgets/space-tools-dock/index.ts b/src/widgets/space-tools-dock/index.ts
new file mode 100644
index 0000000..8df5d40
--- /dev/null
+++ b/src/widgets/space-tools-dock/index.ts
@@ -0,0 +1 @@
+export * from './ui/SpaceToolsDockWidget';
diff --git a/src/widgets/space-tools-dock/model/useSpaceToolsDock.ts b/src/widgets/space-tools-dock/model/useSpaceToolsDock.ts
new file mode 100644
index 0000000..058cebd
--- /dev/null
+++ b/src/widgets/space-tools-dock/model/useSpaceToolsDock.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import { useState } from 'react';
+
+export type SpaceToolPanel = 'room' | 'notes' | 'quick' | null;
+
+export const useSpaceToolsDock = () => {
+ const [activePanel, setActivePanel] = useState(null);
+
+ const togglePanel = (panel: Exclude) => {
+ setActivePanel((current) => (current === panel ? null : panel));
+ };
+
+ const closePanel = () => {
+ setActivePanel(null);
+ };
+
+ return {
+ activePanel,
+ togglePanel,
+ closePanel,
+ };
+};
diff --git a/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
new file mode 100644
index 0000000..3e31aa3
--- /dev/null
+++ b/src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import type { RoomPresence } from '@/entities/room';
+import { CHECK_IN_PHRASES, REACTION_OPTIONS } from '@/entities/session';
+import { useCheckIn } from '@/features/check-in';
+import { useToast } from '@/shared/ui';
+import { cn } from '@/shared/lib/cn';
+import { NotesSheetWidget } from '@/widgets/notes-sheet';
+import { QuickSheetWidget } from '@/widgets/quick-sheet';
+import { RoomSheetWidget } from '@/widgets/room-sheet';
+import { useSpaceToolsDock } from '../model/useSpaceToolsDock';
+
+interface SpaceToolsDockWidgetProps {
+ roomName: string;
+ activeMembers: number;
+ presence: RoomPresence;
+}
+
+const TOOL_BUTTONS: Array<{
+ id: 'room' | 'notes' | 'quick';
+ icon: string;
+ label: string;
+}> = [
+ { id: 'room', icon: '👥', label: 'Room' },
+ { id: 'notes', icon: '📝', label: 'Notes' },
+ { id: 'quick', icon: '⚙️', label: 'Quick' },
+];
+
+export const SpaceToolsDockWidget = ({
+ roomName,
+ activeMembers,
+ presence,
+}: SpaceToolsDockWidgetProps) => {
+ const { pushToast } = useToast();
+ const { lastCheckIn, recordCheckIn } = useCheckIn();
+ const { activePanel, closePanel, togglePanel } = useSpaceToolsDock();
+
+ const handleCheckIn = (message: string) => {
+ recordCheckIn(message);
+ pushToast({ title: `체크인: ${message}` });
+ };
+
+ const handleReaction = (emoji: string) => {
+ pushToast({ title: `리액션: ${emoji}` });
+ };
+
+ return (
+ <>
+ {activePanel ? (
+
+ ) : null}
+
+
+
+ {TOOL_BUTTONS.map((tool) => (
+ togglePanel(tool.id)}
+ className={cn(
+ 'inline-flex h-9 w-9 items-center justify-center rounded-xl border text-base transition-colors',
+ activePanel === tool.id
+ ? 'border-sky-200/75 bg-sky-300/28'
+ : 'border-white/20 bg-white/8 hover:bg-white/15',
+ )}
+ >
+ {tool.icon}
+ {tool.label}
+
+ ))}
+
+
+
+ {activePanel === 'room' ? (
+ handleReaction(reaction.emoji)}
+ />
+ ) : null}
+
+ {activePanel === 'notes' ? (
+ pushToast({ title: `노트 추가: ${note}` })}
+ onNoteRemoved={() => pushToast({ title: '노트를 정리했어요' })}
+ />
+ ) : null}
+
+ {activePanel === 'quick' ? : null}
+ >
+ );
+};
diff --git a/src/widgets/start-ritual-widget/index.ts b/src/widgets/start-ritual-widget/index.ts
new file mode 100644
index 0000000..540ba8e
--- /dev/null
+++ b/src/widgets/start-ritual-widget/index.ts
@@ -0,0 +1 @@
+export * from './ui/StartRitualWidget';
diff --git a/src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx b/src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx
new file mode 100644
index 0000000..22cdcdf
--- /dev/null
+++ b/src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx
@@ -0,0 +1,93 @@
+import { useState } from 'react';
+import type { GoalChip } from '@/entities/session';
+import { Button, Chip, GlassCard } from '@/shared/ui';
+
+interface StartRitualWidgetProps {
+ goalInput: string;
+ selectedGoalId: string | null;
+ goalChips: GoalChip[];
+ onGoalInputChange: (value: string) => void;
+ onGoalChipSelect: (chip: GoalChip) => void;
+ onQuickEnter: () => void;
+ onOpenCustomEntry: () => void;
+}
+
+export const StartRitualWidget = ({
+ goalInput,
+ selectedGoalId,
+ goalChips,
+ onGoalInputChange,
+ onGoalChipSelect,
+ onQuickEnter,
+ onOpenCustomEntry,
+}: StartRitualWidgetProps) => {
+ const [isGoalOpen, setGoalOpen] = useState(true);
+
+ return (
+
+
+
지금, 몰입을 시작해요
+
+ 공간은 들어가서 바꿔도 괜찮아요. 오늘은 한 조각만.
+
+
+
+
+
+
+ 한 줄 메모(선택)
+
+ setGoalOpen((current) => !current)}
+ className="text-xs text-white/62 transition hover:text-white"
+ >
+ {isGoalOpen ? '접기' : '펼치기'}
+
+
+
+ {isGoalOpen ? (
+
+
onGoalInputChange(event.target.value)}
+ placeholder="이번 세션 딱 1가지만 (예: 견적서 1페이지)"
+ className="w-full rounded-xl border border-white/20 bg-slate-950/55 px-3.5 py-3 text-sm text-white placeholder:text-white/45 focus:border-sky-200/60 focus:outline-none"
+ />
+
+
+ {goalChips.map((chip) => (
+ onGoalChipSelect(chip)}
+ >
+ {chip.label}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+ 바로 입장
+
+
+ ⚙
+ 설정하고 입장
+
+
+
+
+ );
+};
diff --git a/src/widgets/stats-overview/index.ts b/src/widgets/stats-overview/index.ts
new file mode 100644
index 0000000..5f6fa4a
--- /dev/null
+++ b/src/widgets/stats-overview/index.ts
@@ -0,0 +1 @@
+export * from './ui/StatsOverviewWidget';
diff --git a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx
new file mode 100644
index 0000000..68d96ba
--- /dev/null
+++ b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx
@@ -0,0 +1,59 @@
+import Link from 'next/link';
+import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
+
+const StatSection = ({
+ title,
+ items,
+}: {
+ title: string;
+ items: Array<{ id: string; label: string; value: string; delta: string }>;
+}) => {
+ return (
+
+ {title}
+
+ {items.map((item) => (
+
+ {item.label}
+ {item.value}
+ {item.delta}
+
+ ))}
+
+
+ );
+};
+
+export const StatsOverviewWidget = () => {
+ return (
+
+ );
+};