맥락:\n- 상단 헤더 요소가 많아 허브의 핵심 흐름(공간 선택 → 목표 입력 → 입장)이 분산되었습니다.\n- 메모/통계/설정 진입을 상단이 아닌 보조 동선으로 옮겨 감성 톤을 유지할 필요가 있었습니다.\n\n변경사항:\n- 우측 아이콘 레일과 우측 드로어를 추가해 Inbox/Stats/Settings를 동일 패턴으로 제공했습니다.\n- TopBar에서 메모 버튼을 제거하고, 멤버십/PRO 동선은 ProfileMenu 드롭다운으로 정리했습니다.\n- Selected Space 박스를 슬림화하고 설명을 1줄로 제한해 시선 분산을 줄였습니다.\n- 추천 공간 카드에 텍스트 가독성 오버레이와 선택 표시(은은한 테두리/체크)를 적용했습니다.\n- Quick Entry에서 커스텀 CTA 무게를 낮추고 전체 톤을 가볍게 조정했습니다.\n- docs/work.template.md를 추가하고 docs/work.md의 현재 작업 지시를 갱신했습니다.\n\n검증:\n- npm run build\n- npx tsc --noEmit\n\n세션-상태: /app 허브가 상단 과밀 없이 레일+드로어 보조 동선으로 정리되었습니다.\n세션-다음: 드로어 패널의 실제 데이터 연결 시 도메인 상태와 연동을 진행합니다.\n세션-리스크: 드로어 포커스 트랩/키보드 동선은 추가 접근성 점검이 필요합니다.
203 lines
6.7 KiB
TypeScript
203 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { ROOM_THEMES } from '@/entities/room';
|
|
import {
|
|
GOAL_CHIPS,
|
|
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 {
|
|
AppUtilityRailWidget,
|
|
type AppUtilityPanelId,
|
|
} from '@/widgets/app-utility-rail';
|
|
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 { thoughts, thoughtCount, clearThoughts } = useThoughtInbox();
|
|
const { selectedRoomId, selectRoom } = useRoomSelection(
|
|
ROOM_THEMES[0].id,
|
|
);
|
|
|
|
const [goalInput, setGoalInput] = useState('');
|
|
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
|
const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
|
|
const [activeUtilityPanel, setActiveUtilityPanel] = useState<AppUtilityPanelId | null>(
|
|
null,
|
|
);
|
|
|
|
const visualMode: AppHubVisualMode = DEFAULT_APP_HUB_VISUAL_MODE;
|
|
const cinematic = visualMode === 'cinematic';
|
|
|
|
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 (
|
|
<div className="relative min-h-screen overflow-hidden text-white">
|
|
<div
|
|
aria-hidden
|
|
className={cn('absolute inset-0')}
|
|
style={{
|
|
backgroundImage: cinematic
|
|
? 'radial-gradient(132% 94% at 8% 0%, rgba(148,163,184,0.34) 0%, rgba(15,23,42,0) 46%), radial-gradient(116% 88% at 94% 6%, rgba(125,211,252,0.18) 0%, rgba(15,23,42,0) 52%), linear-gradient(162deg, #0f172a 0%, #111827 42%, #0b1220 100%)'
|
|
: 'radial-gradient(130% 92% at 8% 0%, rgba(148,163,184,0.22) 0%, rgba(248,250,252,0) 48%), radial-gradient(104% 86% at 90% 8%, rgba(125,211,252,0.2) 0%, rgba(248,250,252,0) 54%), linear-gradient(164deg, #f8fafc 0%, #e2e8f0 48%, #dbe7f5 100%)',
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
'absolute inset-0',
|
|
cinematic
|
|
? 'bg-[radial-gradient(circle_at_24%_16%,rgba(148,163,184,0.14),transparent_36%),radial-gradient(circle_at_78%_14%,rgba(125,211,252,0.1),transparent_34%),linear-gradient(180deg,rgba(2,6,23,0.16)_0%,rgba(2,6,23,0.32)_52%,rgba(2,6,23,0.5)_100%)]'
|
|
: 'bg-[radial-gradient(circle_at_20%_14%,rgba(148,163,184,0.16),transparent_38%),radial-gradient(circle_at_82%_16%,rgba(125,211,252,0.12),transparent_38%),linear-gradient(180deg,rgba(248,250,252,0.32)_0%,rgba(241,245,249,0.12)_40%,rgba(15,23,42,0.26)_100%)]',
|
|
)}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
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.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}
|
|
onOpenBilling={() => router.push('/settings')}
|
|
/>
|
|
|
|
<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">
|
|
<RoomsGalleryWidget
|
|
visualMode={visualMode}
|
|
rooms={ROOM_THEMES}
|
|
selectedRoomId={selectedRoomId}
|
|
onRoomSelect={selectRoom}
|
|
startPanel={(
|
|
<StartRitualWidget
|
|
visualMode={visualMode}
|
|
goalInput={goalInput}
|
|
selectedGoalId={selectedGoalId}
|
|
goalChips={GOAL_CHIPS}
|
|
onGoalInputChange={handleGoalInputChange}
|
|
onGoalChipSelect={handleGoalChipSelect}
|
|
onQuickEnter={handleQuickEnter}
|
|
onOpenCustomEntry={() => setCustomEntryOpen(true)}
|
|
inStage
|
|
/>
|
|
)}
|
|
/>
|
|
</main>
|
|
</div>
|
|
|
|
<CustomEntryWidget
|
|
isOpen={isCustomEntryOpen}
|
|
selectedRoomId={selectedRoomId}
|
|
onSelectRoom={selectRoom}
|
|
onClose={() => setCustomEntryOpen(false)}
|
|
onEnter={handleCustomEnter}
|
|
/>
|
|
|
|
<AppUtilityRailWidget
|
|
visualMode={visualMode}
|
|
activePanel={activeUtilityPanel}
|
|
thoughts={thoughts}
|
|
thoughtCount={thoughtCount}
|
|
onOpenPanel={setActiveUtilityPanel}
|
|
onClosePanel={() => setActiveUtilityPanel(null)}
|
|
onClearInbox={() => {
|
|
clearThoughts();
|
|
pushToast({ title: '메모 인박스를 비웠어요' });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|