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:
2026-02-27 13:30:55 +09:00
parent 583837fb8d
commit cbd9017744
87 changed files with 2900 additions and 176 deletions

View File

@@ -0,0 +1 @@
export * from './ui/AppHubWidget';

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

View File

@@ -0,0 +1 @@
export * from './ui/AppTopBar';

View File

@@ -0,0 +1,27 @@
import type { ViewerProfile } from '@/entities/user';
import { ProfileMenu } from '@/features/profile-menu';
interface AppTopBarProps {
user: ViewerProfile;
oneLiner: string;
onLogout: () => void;
}
export const AppTopBar = ({ user, oneLiner, onLogout }: AppTopBarProps) => {
return (
<header className="sticky top-0 z-20 border-b border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-lg sm:px-6">
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/30 bg-white/10 text-xs font-semibold text-white">
V
</span>
<p className="text-sm font-semibold tracking-tight text-white">VibeRoom</p>
</div>
<p className="hidden text-center text-sm text-white/75 md:block">{oneLiner}</p>
<ProfileMenu user={user} onLogout={onLogout} />
</div>
</header>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/CustomEntryWidget';

View File

@@ -0,0 +1,30 @@
import {
type CustomEntrySelection,
CustomEntryModal,
} from '@/features/custom-entry-modal';
interface CustomEntryWidgetProps {
isOpen: boolean;
selectedRoomId: string;
onSelectRoom: (roomId: string) => void;
onClose: () => void;
onEnter: (selection: CustomEntrySelection) => void;
}
export const CustomEntryWidget = ({
isOpen,
selectedRoomId,
onSelectRoom,
onClose,
onEnter,
}: CustomEntryWidgetProps) => {
return (
<CustomEntryModal
isOpen={isOpen}
selectedRoomId={selectedRoomId}
onSelectRoom={onSelectRoom}
onClose={onClose}
onEnter={onEnter}
/>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/NotesSheetWidget';

View File

@@ -0,0 +1,37 @@
import { DistractionDumpNotesContent } from '@/features/distraction-dump';
interface NotesSheetWidgetProps {
onClose: () => void;
onNoteAdded?: (note: string) => void;
onNoteRemoved?: () => void;
}
export const NotesSheetWidget = ({
onClose,
onNoteAdded,
onNoteRemoved,
}: NotesSheetWidgetProps) => {
return (
<aside className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,340px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
<header className="flex items-center justify-between border-b border-white/12 px-4 py-4">
<h2 className="text-base font-semibold text-white"> </h2>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
>
X
</button>
</header>
<div className="flex-1 overflow-y-auto px-4 py-4">
<DistractionDumpNotesContent
onNoteAdded={onNoteAdded}
onNoteRemoved={onNoteRemoved}
/>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/QuickSheetWidget';

View File

@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
interface QuickSheetWidgetProps {
onClose: () => void;
}
export const QuickSheetWidget = ({ onClose }: QuickSheetWidgetProps) => {
const [immersionMode, setImmersionMode] = useState(false);
const [minimalNotice, setMinimalNotice] = useState(false);
return (
<aside className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,320px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none">
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
<header className="flex items-center justify-between border-b border-white/12 px-4 py-4">
<h2 className="text-base font-semibold text-white">Quick</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
>
X
</button>
</header>
<div className="space-y-3 px-4 py-4">
<button
type="button"
role="switch"
aria-checked={immersionMode}
onClick={() => setImmersionMode((current) => !current)}
className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
>
<span> </span>
<span>{immersionMode ? 'ON' : 'OFF'}</span>
</button>
<button
type="button"
role="switch"
aria-checked={minimalNotice}
onClick={() => setMinimalNotice((current) => !current)}
className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
>
<span> </span>
<span>{minimalNotice ? 'ON' : 'OFF'}</span>
</button>
<p className="text-xs text-white/58">
UI . .
</p>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/RoomSheetWidget';

View File

@@ -0,0 +1,98 @@
import type { RoomPresence } from '@/entities/room';
import type { CheckInPhrase, ReactionOption } from '@/entities/session';
import { CompactCheckInChips } from '@/features/check-in';
import { ReactionIconRow } from '@/features/reactions';
import { Chip } from '@/shared/ui';
interface RoomSheetWidgetProps {
roomName: string;
activeMembers: number;
presence: RoomPresence;
checkInPhrases: CheckInPhrase[];
reactions: ReactionOption[];
lastCheckIn: string | null;
onClose: () => void;
onCheckIn: (message: string) => void;
onReaction: (reaction: ReactionOption) => void;
}
export const RoomSheetWidget = ({
roomName,
activeMembers,
presence,
checkInPhrases,
reactions,
lastCheckIn,
onClose,
onCheckIn,
onReaction,
}: RoomSheetWidgetProps) => {
return (
<aside
className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,340px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none"
>
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
<header className="space-y-3 border-b border-white/12 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-white">{roomName}</h2>
<p className="text-xs text-white/70"> {activeMembers} </p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
>
X
</button>
</div>
<div className="flex flex-wrap gap-1.5">
<Chip tone="accent" className="!cursor-default !px-2 !py-1 text-[11px]">
Focus {presence.focus}
</Chip>
<Chip className="!cursor-default !px-2 !py-1 text-[11px]">
Break {presence.break}
</Chip>
<Chip className="!cursor-default !px-2 !py-1 text-[11px]">
Away {presence.away}
</Chip>
</div>
<div className="space-y-3 rounded-xl border border-white/12 bg-slate-900/45 p-3">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-white/58">
Check-in
</p>
<CompactCheckInChips
phrases={checkInPhrases}
onCheckIn={onCheckIn}
collapsedCount={3}
/>
</div>
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-white/58">
Reactions
</p>
<ReactionIconRow reactions={reactions} onReact={onReaction} />
</div>
</div>
</header>
<div className="flex-1 px-4 py-4">
<p className="text-xs text-white/70">
:{' '}
<span className="font-medium text-white">
{lastCheckIn ?? '아직 체크인이 없습니다'}
</span>
</p>
<p className="mt-2 text-xs text-white/55">
/ .
</p>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/RoomsGalleryWidget';

View File

@@ -0,0 +1,35 @@
import type { RoomTheme } from '@/entities/room';
import { RoomPreviewCard } from '@/features/room-select';
import { GlassCard } from '@/shared/ui';
interface RoomsGalleryWidgetProps {
rooms: RoomTheme[];
selectedRoomId: string;
onRoomSelect: (roomId: string) => void;
}
export const RoomsGalleryWidget = ({
rooms,
selectedRoomId,
onRoomSelect,
}: RoomsGalleryWidgetProps) => {
return (
<GlassCard elevated className="space-y-5 p-5 sm:p-6">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-white"> </h2>
<p className="text-sm text-white/70"> .</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{rooms.map((room) => (
<RoomPreviewCard
key={room.id}
room={room}
selected={room.id === selectedRoomId}
onSelect={onRoomSelect}
/>
))}
</div>
</GlassCard>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/SettingsPanelWidget';

View File

@@ -0,0 +1,110 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions';
import { cn } from '@/shared/lib/cn';
export const SettingsPanelWidget = () => {
const [reduceMotion, setReduceMotion] = useState(false);
const [notificationIntensity, setNotificationIntensity] =
useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본');
const [defaultPresetId, setDefaultPresetId] = useState<
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
>(DEFAULT_PRESET_OPTIONS[0].id);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_80%_0%,rgba(34,211,238,0.16),transparent_48%),linear-gradient(170deg,#020617_0%,#111827_54%,#0f172a_100%)] text-white">
<div className="mx-auto w-full max-w-4xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex items-center justify-between rounded-xl border border-white/12 bg-white/6 px-4 py-3">
<h1 className="text-xl font-semibold">Settings</h1>
<Link
href="/app"
className="rounded-lg border border-white/24 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
>
</Link>
</header>
<div className="space-y-4">
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-white">Reduce Motion</h2>
<p className="mt-1 text-sm text-white/70">
. (UI )
</p>
</div>
<button
type="button"
role="switch"
aria-checked={reduceMotion}
onClick={() => setReduceMotion((current) => !current)}
className={cn(
'inline-flex w-16 items-center rounded-full border px-1 py-1 transition-colors',
reduceMotion
? 'border-sky-200/70 bg-sky-300/28'
: 'border-white/30 bg-white/10',
)}
>
<span
className={cn(
'h-5 w-5 rounded-full bg-white transition-transform duration-200 motion-reduce:transition-none',
reduceMotion ? 'translate-x-9' : 'translate-x-0',
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
<h2 className="text-base font-semibold text-white"> </h2>
<p className="mt-1 text-sm text-white/70"> / .</p>
<div className="mt-3 flex flex-wrap gap-2">
{NOTIFICATION_INTENSITY_OPTIONS.map((option) => (
<button
key={option}
type="button"
onClick={() => setNotificationIntensity(option)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
notificationIntensity === option
? 'border-sky-200/80 bg-sky-300/25 text-sky-50'
: 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
)}
>
{option}
</button>
))}
</div>
</section>
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
<h2 className="text-base font-semibold text-white"> </h2>
<p className="mt-1 text-sm text-white/70"> .</p>
<div className="mt-3 space-y-2">
{DEFAULT_PRESET_OPTIONS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => setDefaultPresetId(preset.id)}
className={cn(
'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
defaultPresetId === preset.id
? 'border-sky-200/75 bg-sky-300/20 text-sky-50'
: 'border-white/18 bg-white/5 text-white/85 hover:bg-white/10',
)}
>
{preset.label}
</button>
))}
</div>
</section>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/SpaceSkeletonWidget';

View File

@@ -0,0 +1,106 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { getRoomBackgroundStyle, getRoomById, ROOM_THEMES } from '@/entities/room';
import { SOUND_PRESETS } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
export const SpaceSkeletonWidget = () => {
const searchParams = useSearchParams();
const roomId = searchParams.get('room') ?? ROOM_THEMES[0].id;
const goal = searchParams.get('goal') ?? '오늘은 한 조각만 집중해요';
const timerLabel = searchParams.get('timer') ?? '25/5';
const soundFromQuery = searchParams.get('sound');
const room = useMemo(() => getRoomById(roomId) ?? ROOM_THEMES[0], [roomId]);
const defaultSoundId =
SOUND_PRESETS.find((preset) => preset.id === soundFromQuery)?.id ??
SOUND_PRESETS[0].id;
const [selectedSoundId, setSelectedSoundId] = useState(defaultSoundId);
useEffect(() => {
setSelectedSoundId(defaultSoundId);
}, [defaultSoundId]);
return (
<div className="relative min-h-screen overflow-x-hidden overflow-y-hidden text-white">
<div
aria-hidden
className="absolute inset-0 w-full"
style={getRoomBackgroundStyle(room)}
/>
<div aria-hidden className="absolute inset-0 w-full bg-slate-950/62" />
<div
aria-hidden
className="absolute inset-0 w-full opacity-35"
style={{
backgroundImage:
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.045) 0 1px, transparent 1px 2px)",
}}
/>
<div className="relative z-10 flex min-h-screen flex-col pr-14">
<header className="flex items-center justify-between border-b border-white/12 bg-slate-950/35 px-4 py-3 backdrop-blur-lg sm:px-6">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/30 bg-white/10 text-xs font-semibold">
V
</span>
<p className="text-sm font-semibold tracking-tight">VibeRoom</p>
</div>
<Link
href="/app"
className="rounded-lg border border-white/30 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
>
</Link>
</header>
<main className="flex-1 px-4 pt-6 sm:px-6">
<div className="mx-auto w-full max-w-5xl">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[0.14em] text-white/65">Current Room</p>
<p className="text-lg font-semibold text-white">{room.name}</p>
<p className="text-xs text-white/70"> {room.activeMembers} </p>
</div>
</div>
</main>
<footer className="border-t border-white/12 bg-slate-950/42 px-4 py-3 backdrop-blur-md sm:px-6">
<div className="mx-auto flex w-full max-w-5xl flex-wrap gap-2">
{SOUND_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => setSelectedSoundId(preset.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
selectedSoundId === preset.id
? 'border-sky-200/75 bg-sky-300/25 text-sky-50'
: 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
)}
>
{preset.label}
</button>
))}
</div>
</footer>
</div>
<SpaceTimerHudWidget timerLabel={timerLabel} goal={goal} />
<SpaceToolsDockWidget
roomName={room.name}
activeMembers={room.activeMembers}
presence={room.presence}
/>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/SpaceTimerHudWidget';

View File

@@ -0,0 +1,56 @@
import { cn } from '@/shared/lib/cn';
import { Restart30sAction } from '@/features/restart-30s';
interface SpaceTimerHudWidgetProps {
timerLabel: string;
goal: string;
className?: string;
}
const HUD_ACTIONS = [
{ id: 'start', label: '시작', icon: '▶' },
{ id: 'pause', label: '일시정지', icon: '⏸' },
{ id: 'reset', label: '리셋', icon: '↺' },
] as const;
export const SpaceTimerHudWidget = ({
timerLabel,
goal,
className,
}: SpaceTimerHudWidgetProps) => {
return (
<div className={cn('pointer-events-none fixed inset-x-0 bottom-[4.7rem] z-20 px-4 pr-16 sm:px-6', className)}>
<div className="mx-auto w-full max-w-xl pointer-events-auto">
<section className="flex h-16 items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/25 px-3.5 backdrop-blur-sm">
<div className="min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/62">
Focus
</span>
<span className="text-2xl font-semibold tracking-tight text-white">25:00</span>
<span className="text-[11px] text-white/62">{timerLabel}</span>
</div>
<p className="truncate text-[11px] text-white/58">: {goal}</p>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5">
{HUD_ACTIONS.map((action) => (
<button
key={action.id}
type="button"
title={action.label}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/8 text-sm text-white/82 transition-colors hover:bg-white/14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80"
>
<span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span>
</button>
))}
</div>
<Restart30sAction />
</div>
</section>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/SpaceToolsDockWidget';

View File

@@ -0,0 +1,23 @@
'use client';
import { useState } from 'react';
export type SpaceToolPanel = 'room' | 'notes' | 'quick' | null;
export const useSpaceToolsDock = () => {
const [activePanel, setActivePanel] = useState<SpaceToolPanel>(null);
const togglePanel = (panel: Exclude<SpaceToolPanel, null>) => {
setActivePanel((current) => (current === panel ? null : panel));
};
const closePanel = () => {
setActivePanel(null);
};
return {
activePanel,
togglePanel,
closePanel,
};
};

View File

@@ -0,0 +1,105 @@
'use client';
import type { RoomPresence } from '@/entities/room';
import { CHECK_IN_PHRASES, REACTION_OPTIONS } from '@/entities/session';
import { useCheckIn } from '@/features/check-in';
import { useToast } from '@/shared/ui';
import { cn } from '@/shared/lib/cn';
import { NotesSheetWidget } from '@/widgets/notes-sheet';
import { QuickSheetWidget } from '@/widgets/quick-sheet';
import { RoomSheetWidget } from '@/widgets/room-sheet';
import { useSpaceToolsDock } from '../model/useSpaceToolsDock';
interface SpaceToolsDockWidgetProps {
roomName: string;
activeMembers: number;
presence: RoomPresence;
}
const TOOL_BUTTONS: Array<{
id: 'room' | 'notes' | 'quick';
icon: string;
label: string;
}> = [
{ id: 'room', icon: '👥', label: 'Room' },
{ id: 'notes', icon: '📝', label: 'Notes' },
{ id: 'quick', icon: '⚙️', label: 'Quick' },
];
export const SpaceToolsDockWidget = ({
roomName,
activeMembers,
presence,
}: SpaceToolsDockWidgetProps) => {
const { pushToast } = useToast();
const { lastCheckIn, recordCheckIn } = useCheckIn();
const { activePanel, closePanel, togglePanel } = useSpaceToolsDock();
const handleCheckIn = (message: string) => {
recordCheckIn(message);
pushToast({ title: `체크인: ${message}` });
};
const handleReaction = (emoji: string) => {
pushToast({ title: `리액션: ${emoji}` });
};
return (
<>
{activePanel ? (
<button
type="button"
aria-label="시트 닫기"
onClick={closePanel}
className="fixed inset-0 z-30 bg-slate-950/10"
/>
) : null}
<div className="fixed right-2 top-1/2 z-50 -translate-y-1/2">
<div className="flex w-12 flex-col items-center gap-2 rounded-2xl border border-white/20 bg-slate-950/66 py-2 shadow-lg shadow-slate-950/60 backdrop-blur-xl">
{TOOL_BUTTONS.map((tool) => (
<button
key={tool.id}
type="button"
title={tool.label}
onClick={() => togglePanel(tool.id)}
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-xl border text-base transition-colors',
activePanel === tool.id
? 'border-sky-200/75 bg-sky-300/28'
: 'border-white/20 bg-white/8 hover:bg-white/15',
)}
>
<span aria-hidden>{tool.icon}</span>
<span className="sr-only">{tool.label}</span>
</button>
))}
</div>
</div>
{activePanel === 'room' ? (
<RoomSheetWidget
roomName={roomName}
activeMembers={activeMembers}
presence={presence}
checkInPhrases={CHECK_IN_PHRASES}
reactions={REACTION_OPTIONS}
lastCheckIn={lastCheckIn}
onClose={closePanel}
onCheckIn={handleCheckIn}
onReaction={(reaction) => handleReaction(reaction.emoji)}
/>
) : null}
{activePanel === 'notes' ? (
<NotesSheetWidget
onClose={closePanel}
onNoteAdded={(note) => pushToast({ title: `노트 추가: ${note}` })}
onNoteRemoved={() => pushToast({ title: '노트를 정리했어요' })}
/>
) : null}
{activePanel === 'quick' ? <QuickSheetWidget onClose={closePanel} /> : null}
</>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/StartRitualWidget';

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import type { GoalChip } from '@/entities/session';
import { Button, Chip, GlassCard } from '@/shared/ui';
interface StartRitualWidgetProps {
goalInput: string;
selectedGoalId: string | null;
goalChips: GoalChip[];
onGoalInputChange: (value: string) => void;
onGoalChipSelect: (chip: GoalChip) => void;
onQuickEnter: () => void;
onOpenCustomEntry: () => void;
}
export const StartRitualWidget = ({
goalInput,
selectedGoalId,
goalChips,
onGoalInputChange,
onGoalChipSelect,
onQuickEnter,
onOpenCustomEntry,
}: StartRitualWidgetProps) => {
const [isGoalOpen, setGoalOpen] = useState(true);
return (
<GlassCard elevated className="space-y-5 p-5 sm:p-6">
<div>
<h1 className="text-2xl font-semibold text-white">, </h1>
<p className="mt-2 text-sm leading-relaxed text-white/72">
. .
</p>
</div>
<section className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-[0.13em] text-white/65">
()
</label>
<button
type="button"
onClick={() => setGoalOpen((current) => !current)}
className="text-xs text-white/62 transition hover:text-white"
>
{isGoalOpen ? '접기' : '펼치기'}
</button>
</div>
{isGoalOpen ? (
<div className="space-y-3">
<input
value={goalInput}
onChange={(event) => onGoalInputChange(event.target.value)}
placeholder="이번 세션 딱 1가지만 (예: 견적서 1페이지)"
className="w-full rounded-xl border border-white/20 bg-slate-950/55 px-3.5 py-3 text-sm text-white placeholder:text-white/45 focus:border-sky-200/60 focus:outline-none"
/>
<div className="flex flex-wrap gap-2">
{goalChips.map((chip) => (
<Chip
key={chip.id}
active={selectedGoalId === chip.id}
onClick={() => onGoalChipSelect(chip)}
>
{chip.label}
</Chip>
))}
</div>
</div>
) : null}
</section>
<section className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
<Button
className="w-full px-6 sm:w-auto sm:min-w-[180px]"
onClick={onQuickEnter}
>
</Button>
<Button
variant="outline"
className="w-full sm:w-auto sm:min-w-[152px] !bg-white/6 !text-white/84 !ring-white/24 hover:!bg-white/12"
onClick={onOpenCustomEntry}
>
<span aria-hidden></span>
</Button>
</div>
</section>
</GlassCard>
);
};

View File

@@ -0,0 +1 @@
export * from './ui/StatsOverviewWidget';

View File

@@ -0,0 +1,59 @@
import Link from 'next/link';
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
const StatSection = ({
title,
items,
}: {
title: string;
items: Array<{ id: string; label: string; value: string; delta: string }>;
}) => {
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{items.map((item) => (
<article
key={item.id}
className="rounded-xl border border-white/16 bg-white/8 p-4"
>
<p className="text-xs text-white/62">{item.label}</p>
<p className="mt-2 text-xl font-semibold text-white">{item.value}</p>
<p className="mt-1 text-xs text-sky-200">{item.delta}</p>
</article>
))}
</div>
</section>
);
};
export const StatsOverviewWidget = () => {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_20%_0%,rgba(56,189,248,0.18),transparent_45%),linear-gradient(170deg,#020617_0%,#0f172a_52%,#111827_100%)] text-white">
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex items-center justify-between rounded-xl border border-white/12 bg-white/6 px-4 py-3">
<h1 className="text-xl font-semibold">Stats</h1>
<Link
href="/app"
className="rounded-lg border border-white/24 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
>
</Link>
</header>
<div className="space-y-6">
<StatSection title="오늘" items={TODAY_STATS} />
<StatSection title="최근 7일" items={WEEKLY_STATS} />
<section className="space-y-3">
<h2 className="text-lg font-semibold text-white"> </h2>
<div className="rounded-xl border border-dashed border-white/25 bg-white/5 p-5">
<div className="h-52 rounded-lg border border-white/10 bg-[linear-gradient(180deg,rgba(148,163,184,0.08),rgba(148,163,184,0.02))]" />
<p className="mt-3 text-xs text-white/60"> </p>
</div>
</section>
</div>
</div>
</div>
);
};