Files
viberoom-web/src/widgets/app-hub/ui/AppHubWidget.tsx
corpi a2bebb3485 feat(app-hub): 허브 도구 레일과 입장 동선을 정리
맥락:\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세션-리스크: 드로어 포커스 트랩/키보드 동선은 추가 접근성 점검이 필요합니다.
2026-03-02 12:17:35 +09:00

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>
);
};