feat(app-hub): 씬 중심 허브 화면으로 전면 리빌드
맥락: - /app 첫 화면의 정보량이 많아 LifeAt/Portal 같은 몰입형 경험과 거리가 있었습니다. - Start, 공간 선택, 메모 진입을 점진 노출 구조로 재정렬할 필요가 있었습니다. 변경사항: - AppHub를 데스크톱 2열(컨트롤/씬) 구조와 모바일 스택 구조로 재편했습니다. - Start CTA를 컴팩트 위계로 정리하고 커스텀 액션을 2순위 텍스트형으로 유지했습니다. - Room 영역을 선택 Hero 1개 + 추천 썸네일 스트립 + 더보기 시트 구조로 전면 변경했습니다. - 최근 생각은 단일 진입점 + 인박스 시트로 통합하고 localStorage 기반 thought inbox를 추가했습니다. - /space 종료 동선에 세션 요약 시트(최근 메모 3개)를 연결해 허브 복귀 흐름을 정리했습니다. - AppHub 기본 비주얼 모드를 cinematic으로 고정하고 배경 오버레이를 가독성 중심으로 재조정했습니다. 검증: - npx tsc --noEmit - npm run build 세션-상태: 씬 중심 허브 리빌드와 메모/시트 기반 점진 노출 구조 반영 완료 세션-다음: 실제 사용자 테스트 후 카드/텍스트 밀도 미세 조정 세션-리스크: 실디바이스 대비(특히 저사양 모바일)에서 배경/블러 렌더링 비용 확인 필요
This commit is contained in:
@@ -8,16 +8,24 @@ import {
|
||||
SOUND_PRESETS,
|
||||
TIMER_PRESETS,
|
||||
TODAY_ONE_LINER,
|
||||
useThoughtInbox,
|
||||
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 {
|
||||
DEFAULT_APP_HUB_VISUAL_MODE,
|
||||
type AppHubVisualMode,
|
||||
} from '@/shared/config/appHubVisualMode';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
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';
|
||||
import { ThoughtInboxSheet } from '@/widgets/thought-inbox-sheet';
|
||||
import { ThoughtSummaryEntryWidget } from '@/widgets/thought-summary-entry';
|
||||
|
||||
const buildSpaceQuery = (
|
||||
roomId: string,
|
||||
@@ -43,6 +51,7 @@ const buildSpaceQuery = (
|
||||
export const AppHubWidget = () => {
|
||||
const router = useRouter();
|
||||
const { pushToast } = useToast();
|
||||
const { thoughts, thoughtCount, clearThoughts } = useThoughtInbox();
|
||||
const { selectedRoom, selectedRoomId, selectRoom } = useRoomSelection(
|
||||
ROOM_THEMES[0].id,
|
||||
);
|
||||
@@ -50,6 +59,10 @@ export const AppHubWidget = () => {
|
||||
const [goalInput, setGoalInput] = useState('');
|
||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||
const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
|
||||
const [isThoughtInboxOpen, setThoughtInboxOpen] = useState(false);
|
||||
|
||||
const visualMode: AppHubVisualMode = DEFAULT_APP_HUB_VISUAL_MODE;
|
||||
const cinematic = visualMode === 'cinematic';
|
||||
|
||||
const enterSpace = (soundPresetId: string, timerLabel: string) => {
|
||||
const query = buildSpaceQuery(
|
||||
@@ -95,50 +108,85 @@ export const AppHubWidget = () => {
|
||||
<div className="relative min-h-screen overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 scale-[1.04] blur-[18px]"
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
cinematic && 'scale-[1.01]',
|
||||
)}
|
||||
style={{
|
||||
...getRoomCardBackgroundStyle(selectedRoom),
|
||||
filter: 'brightness(1.05) saturate(0.9)',
|
||||
filter: cinematic
|
||||
? 'brightness(0.86) saturate(1.06) contrast(1.03)'
|
||||
: 'brightness(0.94) saturate(0.96)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_14%_0%,rgba(255,255,255,0.54),transparent_48%),radial-gradient(circle_at_86%_18%,rgba(255,255,255,0.36),transparent_46%),linear-gradient(165deg,rgba(248,250,252,0.26)_0%,rgba(226,232,240,0.22)_52%,rgba(203,213,225,0.3)_100%)]"
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
cinematic
|
||||
? 'bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.34)_50%,rgba(2,6,23,0.52)_100%)]'
|
||||
: 'bg-[linear-gradient(180deg,rgba(248,250,252,0.44)_0%,rgba(241,245,249,0.22)_42%,rgba(15,23,42,0.3)_100%)]',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-[0.14]"
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
cinematic ? 'opacity-[0.12]' : 'opacity-[0.06]',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.022) 0 1px, transparent 1px 2px)",
|
||||
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.016) 0 1px, transparent 1px 2px)",
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_74%,transparent_12%,rgba(2,6,23,0.32)_100%)]"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col">
|
||||
<AppTopBar
|
||||
user={MOCK_VIEWER}
|
||||
oneLiner={TODAY_ONE_LINER}
|
||||
onLogout={handleLogout}
|
||||
visualMode={visualMode}
|
||||
thoughtCount={thoughtCount}
|
||||
onOpenThoughtInbox={() => setThoughtInboxOpen(true)}
|
||||
onOpenBilling={() => router.push('/settings')}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 pb-8 pt-6 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(320px,420px),1fr]">
|
||||
<StartRitualWidget
|
||||
goalInput={goalInput}
|
||||
selectedGoalId={selectedGoalId}
|
||||
goalChips={GOAL_CHIPS}
|
||||
onGoalInputChange={handleGoalInputChange}
|
||||
onGoalChipSelect={handleGoalChipSelect}
|
||||
onQuickEnter={handleQuickEnter}
|
||||
onOpenCustomEntry={() => setCustomEntryOpen(true)}
|
||||
/>
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-8 pt-5 sm:px-6 sm:pt-6 lg:px-8 lg:pb-10">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:gap-5">
|
||||
<section className="order-1">
|
||||
<StartRitualWidget
|
||||
visualMode={visualMode}
|
||||
goalInput={goalInput}
|
||||
selectedGoalId={selectedGoalId}
|
||||
goalChips={GOAL_CHIPS}
|
||||
onGoalInputChange={handleGoalInputChange}
|
||||
onGoalChipSelect={handleGoalChipSelect}
|
||||
onQuickEnter={handleQuickEnter}
|
||||
onOpenCustomEntry={() => setCustomEntryOpen(true)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<RoomsGalleryWidget
|
||||
rooms={ROOM_THEMES}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomSelect={selectRoom}
|
||||
/>
|
||||
<section className="order-2 lg:row-span-2">
|
||||
<RoomsGalleryWidget
|
||||
visualMode={visualMode}
|
||||
rooms={ROOM_THEMES}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomSelect={selectRoom}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="order-3">
|
||||
<ThoughtSummaryEntryWidget
|
||||
visualMode={visualMode}
|
||||
thoughtCount={thoughtCount}
|
||||
onOpen={() => setThoughtInboxOpen(true)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -150,6 +198,16 @@ export const AppHubWidget = () => {
|
||||
onClose={() => setCustomEntryOpen(false)}
|
||||
onEnter={handleCustomEnter}
|
||||
/>
|
||||
|
||||
<ThoughtInboxSheet
|
||||
isOpen={isThoughtInboxOpen}
|
||||
thoughts={thoughts}
|
||||
onClose={() => setThoughtInboxOpen(false)}
|
||||
onClear={() => {
|
||||
clearThoughts();
|
||||
pushToast({ title: '메모 인박스를 비웠어요' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user