feat(fsd): 허브·스페이스 중심 UI 목업 구조로 재편
맥락: - 기존 라우트/컴포넌트 구조를 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 실패 가능
This commit is contained in:
147
src/widgets/app-hub/ui/AppHubWidget.tsx
Normal file
147
src/widgets/app-hub/ui/AppHubWidget.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="relative min-h-screen overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
style={getRoomBackgroundStyle(selectedRoom)}
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-slate-950/60" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-40"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 2px)",
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col">
|
||||
<AppTopBar
|
||||
user={MOCK_VIEWER}
|
||||
oneLiner={TODAY_ONE_LINER}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
<RoomsGalleryWidget
|
||||
rooms={ROOM_THEMES}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomSelect={selectRoom}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CustomEntryWidget
|
||||
isOpen={isCustomEntryOpen}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={selectRoom}
|
||||
onClose={() => setCustomEntryOpen(false)}
|
||||
onEnter={handleCustomEnter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user