From cbd9017744dcefca143fec74e75f805227013cfd Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 27 Feb 2026 13:30:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(fsd):=20=ED=97=88=EB=B8=8C=C2=B7=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A4=91=EC=8B=AC=20UI=20?= =?UTF-8?q?=EB=AA=A9=EC=97=85=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - 기존 라우트/컴포넌트 구조를 FSD 기준으로 정리하고, /app 허브와 /space 집중 화면 중심의 목업 흐름을 구성하기 위해 변경사항: - App Router 구조를 /landing, /app, /space, /stats, /settings 중심으로 재배치 - entities/session/room/user 더미 데이터와 타입 정의 추가 - features(커스텀 입장, 룸 선택, 체크인, 리액션, 30초 리스타트 등) 단위로 로직 분리 - widgets(허브, 룸 갤러리, 타이머 HUD, 툴 도크 등) 조합형 UI 추가 - shared 공용 UI(Button/Chip/Modal/Toast 등) 및 유틸(cn/useReducedMotion) 정비 - 로그인 후 이동 경로를 /dashboard 에서 /app 으로 변경 - README를 현재 프로젝트 구조/라우트/구현 범위 기준으로 갱신 검증: - npx tsc --noEmit 세션-상태: 허브·스페이스 목업이 FSD 레이어로 동작 가능하도록 정리됨 세션-다음: /space 상단 및 도크의 인원 수 카피를 분위기형 카피로 후속 정리 세션-리스크: build는 네트워크 환경에서 Google Fonts fetch 실패 가능 --- README.md | 53 +++--- src/app/(app)/app/page.tsx | 5 + src/app/(app)/dashboard/page.tsx | 106 ------------ src/app/(app)/settings/page.tsx | 5 + src/app/(app)/space/page.tsx | 10 ++ src/app/(app)/stats/page.tsx | 5 + src/app/{(marketing) => (landing)}/page.tsx | 0 src/app/globals.css | 22 +++ src/app/layout.tsx | 3 +- src/app/providers.tsx | 8 + src/entities/room/index.ts | 2 + src/entities/room/model/rooms.ts | 157 +++++++++++++++++ src/entities/room/model/types.ts | 21 +++ src/entities/session/index.ts | 2 + src/entities/session/model/mockSession.ts | 68 ++++++++ src/entities/session/model/types.ts | 34 ++++ src/entities/user/index.ts | 2 + src/entities/user/model/mockUser.ts | 7 + src/entities/user/model/types.ts | 5 + src/features/auth/hooks/useSocialLogin.ts | 19 ++- src/features/check-in/index.ts | 3 + src/features/check-in/model/useCheckIn.ts | 16 ++ src/features/check-in/ui/CheckInChips.tsx | 32 ++++ .../check-in/ui/CompactCheckInChips.tsx | 53 ++++++ src/features/custom-entry-modal/index.ts | 2 + .../model/useCustomEntryForm.ts | 70 ++++++++ .../ui/CustomEntryModal.tsx | 159 ++++++++++++++++++ src/features/distraction-dump/index.ts | 4 + .../model/useDistractionDump.ts | 38 +++++ .../model/useDistractionNotes.ts | 68 ++++++++ .../ui/DistractionDumpNotesContent.tsx | 90 ++++++++++ .../ui/DistractionDumpPanel.tsx | 67 ++++++++ src/features/profile-menu/index.ts | 1 + src/features/profile-menu/ui/ProfileMenu.tsx | 29 ++++ src/features/reactions/index.ts | 2 + src/features/reactions/ui/ReactionButtons.tsx | 23 +++ src/features/reactions/ui/ReactionIconRow.tsx | 31 ++++ src/features/restart-30s/index.ts | 1 + .../restart-30s/model/useRestart30s.ts | 18 ++ .../restart-30s/ui/Restart30sAction.tsx | 31 ++++ src/features/room-select/index.ts | 2 + .../room-select/model/useRoomSelection.ts | 20 +++ .../room-select/ui/RoomPreviewCard.tsx | 62 +++++++ .../space/components/FocusRoomScreen.tsx | 84 +++++++++ .../space/components/RoomEntryForm.tsx | 39 +++++ .../space/hooks/useDashboardViewModel.ts | 30 ++++ .../space/hooks/useFocusRoomViewModel.ts | 31 ++++ src/features/space/hooks/useRoomEntry.ts | 33 ++++ src/features/space/index.ts | 4 + src/shared/config/settingsOptions.ts | 7 + src/shared/lib/cn.ts | 3 + src/shared/lib/useReducedMotion.ts | 23 +++ src/shared/ui/Button.tsx | 92 +++++----- src/shared/ui/Chip.tsx | 42 +++++ src/shared/ui/Dropdown.tsx | 102 +++++++++++ src/shared/ui/GlassCard.tsx | 26 +++ src/shared/ui/Modal.tsx | 97 +++++++++++ src/shared/ui/Tabs.tsx | 36 ++++ src/shared/ui/Toast.tsx | 68 ++++++++ src/shared/ui/index.ts | 7 + src/widgets/app-hub/index.ts | 1 + src/widgets/app-hub/ui/AppHubWidget.tsx | 147 ++++++++++++++++ src/widgets/app-top-bar/index.ts | 1 + src/widgets/app-top-bar/ui/AppTopBar.tsx | 27 +++ src/widgets/custom-entry-widget/index.ts | 1 + .../ui/CustomEntryWidget.tsx | 30 ++++ src/widgets/notes-sheet/index.ts | 1 + .../notes-sheet/ui/NotesSheetWidget.tsx | 37 ++++ src/widgets/quick-sheet/index.ts | 1 + .../quick-sheet/ui/QuickSheetWidget.tsx | 57 +++++++ src/widgets/room-sheet/index.ts | 1 + src/widgets/room-sheet/ui/RoomSheetWidget.tsx | 98 +++++++++++ src/widgets/rooms-gallery-widget/index.ts | 1 + .../ui/RoomsGalleryWidget.tsx | 35 ++++ src/widgets/settings-panel/index.ts | 1 + .../settings-panel/ui/SettingsPanelWidget.tsx | 110 ++++++++++++ src/widgets/space-shell/index.ts | 1 + .../space-shell/ui/SpaceSkeletonWidget.tsx | 106 ++++++++++++ src/widgets/space-timer-hud/index.ts | 1 + .../ui/SpaceTimerHudWidget.tsx | 56 ++++++ src/widgets/space-tools-dock/index.ts | 1 + .../model/useSpaceToolsDock.ts | 23 +++ .../ui/SpaceToolsDockWidget.tsx | 105 ++++++++++++ src/widgets/start-ritual-widget/index.ts | 1 + .../ui/StartRitualWidget.tsx | 93 ++++++++++ src/widgets/stats-overview/index.ts | 1 + .../stats-overview/ui/StatsOverviewWidget.tsx | 59 +++++++ 87 files changed, 2900 insertions(+), 176 deletions(-) create mode 100644 src/app/(app)/app/page.tsx delete mode 100644 src/app/(app)/dashboard/page.tsx create mode 100644 src/app/(app)/settings/page.tsx create mode 100644 src/app/(app)/space/page.tsx create mode 100644 src/app/(app)/stats/page.tsx rename src/app/{(marketing) => (landing)}/page.tsx (100%) create mode 100644 src/app/providers.tsx create mode 100644 src/entities/room/index.ts create mode 100644 src/entities/room/model/rooms.ts create mode 100644 src/entities/room/model/types.ts create mode 100644 src/entities/session/index.ts create mode 100644 src/entities/session/model/mockSession.ts create mode 100644 src/entities/session/model/types.ts create mode 100644 src/entities/user/index.ts create mode 100644 src/entities/user/model/mockUser.ts create mode 100644 src/entities/user/model/types.ts create mode 100644 src/features/check-in/index.ts create mode 100644 src/features/check-in/model/useCheckIn.ts create mode 100644 src/features/check-in/ui/CheckInChips.tsx create mode 100644 src/features/check-in/ui/CompactCheckInChips.tsx create mode 100644 src/features/custom-entry-modal/index.ts create mode 100644 src/features/custom-entry-modal/model/useCustomEntryForm.ts create mode 100644 src/features/custom-entry-modal/ui/CustomEntryModal.tsx create mode 100644 src/features/distraction-dump/index.ts create mode 100644 src/features/distraction-dump/model/useDistractionDump.ts create mode 100644 src/features/distraction-dump/model/useDistractionNotes.ts create mode 100644 src/features/distraction-dump/ui/DistractionDumpNotesContent.tsx create mode 100644 src/features/distraction-dump/ui/DistractionDumpPanel.tsx create mode 100644 src/features/profile-menu/index.ts create mode 100644 src/features/profile-menu/ui/ProfileMenu.tsx create mode 100644 src/features/reactions/index.ts create mode 100644 src/features/reactions/ui/ReactionButtons.tsx create mode 100644 src/features/reactions/ui/ReactionIconRow.tsx create mode 100644 src/features/restart-30s/index.ts create mode 100644 src/features/restart-30s/model/useRestart30s.ts create mode 100644 src/features/restart-30s/ui/Restart30sAction.tsx create mode 100644 src/features/room-select/index.ts create mode 100644 src/features/room-select/model/useRoomSelection.ts create mode 100644 src/features/room-select/ui/RoomPreviewCard.tsx create mode 100644 src/features/space/components/FocusRoomScreen.tsx create mode 100644 src/features/space/components/RoomEntryForm.tsx create mode 100644 src/features/space/hooks/useDashboardViewModel.ts create mode 100644 src/features/space/hooks/useFocusRoomViewModel.ts create mode 100644 src/features/space/hooks/useRoomEntry.ts create mode 100644 src/features/space/index.ts create mode 100644 src/shared/config/settingsOptions.ts create mode 100644 src/shared/lib/cn.ts create mode 100644 src/shared/lib/useReducedMotion.ts create mode 100644 src/shared/ui/Chip.tsx create mode 100644 src/shared/ui/Dropdown.tsx create mode 100644 src/shared/ui/GlassCard.tsx create mode 100644 src/shared/ui/Modal.tsx create mode 100644 src/shared/ui/Tabs.tsx create mode 100644 src/shared/ui/Toast.tsx create mode 100644 src/shared/ui/index.ts create mode 100644 src/widgets/app-hub/index.ts create mode 100644 src/widgets/app-hub/ui/AppHubWidget.tsx create mode 100644 src/widgets/app-top-bar/index.ts create mode 100644 src/widgets/app-top-bar/ui/AppTopBar.tsx create mode 100644 src/widgets/custom-entry-widget/index.ts create mode 100644 src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx create mode 100644 src/widgets/notes-sheet/index.ts create mode 100644 src/widgets/notes-sheet/ui/NotesSheetWidget.tsx create mode 100644 src/widgets/quick-sheet/index.ts create mode 100644 src/widgets/quick-sheet/ui/QuickSheetWidget.tsx create mode 100644 src/widgets/room-sheet/index.ts create mode 100644 src/widgets/room-sheet/ui/RoomSheetWidget.tsx create mode 100644 src/widgets/rooms-gallery-widget/index.ts create mode 100644 src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx create mode 100644 src/widgets/settings-panel/index.ts create mode 100644 src/widgets/settings-panel/ui/SettingsPanelWidget.tsx create mode 100644 src/widgets/space-shell/index.ts create mode 100644 src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx create mode 100644 src/widgets/space-timer-hud/index.ts create mode 100644 src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx create mode 100644 src/widgets/space-tools-dock/index.ts create mode 100644 src/widgets/space-tools-dock/model/useSpaceToolsDock.ts create mode 100644 src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx create mode 100644 src/widgets/start-ritual-widget/index.ts create mode 100644 src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx create mode 100644 src/widgets/stats-overview/index.ts create mode 100644 src/widgets/stats-overview/ui/StatsOverviewWidget.tsx 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

-
- -
- - -
-
- -
-
- 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 ? ( + + ) : 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) => ( + + ))} +
+ ) : null} + + {activeTab === 'sound' ? ( +
+ {SOUND_PRESETS.map((preset) => ( + setSelectedSoundId(preset.id)} + > + {preset.label} + + ))} +
+ ) : null} + + {activeTab === 'timer' ? ( +
+
+ {TIMER_PRESETS.map((preset) => ( + setSelectedTimerId(preset.id)} + > + {preset.label} + + ))} +
+ + {selectedTimerId === 'custom' ? ( +
+ + + +
+ ) : null} +
+ ) : 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 ( +
+
+