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,7 @@
export const NOTIFICATION_INTENSITY_OPTIONS = ['조용함', '기본', '강함'] as const;
export const DEFAULT_PRESET_OPTIONS = [
{ id: 'balanced', label: 'Balanced 25/5 + Rain Focus' },
{ id: 'deep-work', label: 'Deep Work 50/10 + Deep White' },
{ id: 'gentle', label: 'Gentle 25/5 + Silent' },
] as const;

3
src/shared/lib/cn.ts Normal file
View File

@@ -0,0 +1,3 @@
export const cn = (...classes: Array<string | false | null | undefined>) => {
return classes.filter(Boolean).join(' ');
};

View File

@@ -0,0 +1,23 @@
'use client';
import { useEffect, useState } from 'react';
const QUERY = '(prefers-reduced-motion: reduce)';
export const useReducedMotion = () => {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const media = window.matchMedia(QUERY);
const update = () => setReduced(media.matches);
update();
media.addEventListener('change', update);
return () => {
media.removeEventListener('change', update);
};
}, []);
return reduced;
};

View File

@@ -1,65 +1,65 @@
import React, { ButtonHTMLAttributes } from 'react';
import Link, { LinkProps } from 'next/link';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import Link from 'next/link';
import { cn } from '@/shared/lib/cn';
// 1. 버튼 Variant(스타일) 및 Size 타입 정의
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg' | 'full';
// 2. 공통 Props 인터페이스 정의 (HTML 버튼 속성 + Link 속성 혼합)
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
interface CommonButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
href?: string; // href가 주어지면 Next.js Link 컴포넌트로 렌더링
className?: string;
children: React.ReactNode;
children: ReactNode;
}
/**
* VibeRoom 공통 버튼 컴포넌트
* (3-Color System 엄격 적용: #304d6d, #63adf2, #a7cced)
*/
export const Button = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(
{ variant = 'primary', size = 'md', href, className = '', children, ...props },
ref
) => {
// 공통 베이스 스타일
const baseStyle = "inline-flex items-center justify-center font-bold rounded-xl transition-all duration-200";
type LinkButtonProps = CommonButtonProps & {
href: string;
};
// Variant별 테마 (VibeRoom 3-Color System)
const variants: Record<ButtonVariant, string> = {
primary: "bg-brand-primary text-white hover:bg-brand-primary/90 shadow-sm",
secondary: "bg-slate-50 text-brand-dark hover:bg-slate-100",
outline: "bg-white/50 text-brand-dark border border-brand-dark/20 hover:bg-white shadow-sm",
ghost: "bg-transparent text-brand-dark/80 hover:text-brand-primary",
};
type NativeButtonProps = CommonButtonProps &
ButtonHTMLAttributes<HTMLButtonElement> & {
href?: undefined;
};
// 크기별 테마
const sizes: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-sm",
md: "px-5 py-2.5 text-base",
lg: "px-8 py-4 text-lg",
full: "w-full py-3 px-4 text-base",
};
export type ButtonProps = LinkButtonProps | NativeButtonProps;
const combinedClassName = `${baseStyle} ${variants[variant]} ${sizes[size]} ${className}`;
const baseStyle =
'inline-flex items-center justify-center rounded-xl font-semibold transition-all duration-200 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 disabled:cursor-not-allowed disabled:opacity-55';
// href 속성이 있으면 Next.js Link로 렌더링
if (href) {
return (
<Link href={href} className={combinedClassName} ref={ref as React.Ref<HTMLAnchorElement>}>
{children}
</Link>
);
}
const variants: Record<ButtonVariant, string> = {
primary: 'bg-brand-primary text-white shadow-sm hover:bg-brand-primary/90',
secondary: 'bg-slate-100 text-brand-dark hover:bg-slate-200',
outline: 'bg-white/80 text-brand-dark ring-1 ring-slate-300 hover:bg-white',
ghost: 'bg-transparent text-brand-dark/80 hover:bg-brand-dark/8 hover:text-brand-dark',
};
// 기본은 HTML Button
const sizes: Record<ButtonSize, string> = {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base',
full: 'h-11 w-full px-4 text-sm',
};
export const Button = ({
variant = 'primary',
size = 'md',
className,
children,
...props
}: ButtonProps) => {
const style = cn(baseStyle, variants[variant], sizes[size], className);
if ('href' in props && props.href) {
return (
<button className={combinedClassName} ref={ref as React.Ref<HTMLButtonElement>} {...props}>
<Link href={props.href} className={style}>
{children}
</button>
</Link>
);
}
);
Button.displayName = 'Button';
return (
<button {...props} className={style}>
{children}
</button>
);
};

42
src/shared/ui/Chip.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
type ChipTone = 'neutral' | 'accent' | 'muted';
interface ChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
tone?: ChipTone;
children: ReactNode;
}
const toneStyles: Record<ChipTone, string> = {
neutral:
'bg-white/10 text-white/90 ring-1 ring-white/20 hover:bg-white/16',
accent:
'bg-sky-300/25 text-sky-100 ring-1 ring-sky-200/55 hover:bg-sky-300/35',
muted:
'bg-slate-300/25 text-slate-100 ring-1 ring-slate-200/40 hover:bg-slate-300/32',
};
export const Chip = ({
active = false,
tone = 'neutral',
className,
children,
...props
}: ChipProps) => {
return (
<button
type="button"
{...props}
className={cn(
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-200 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
toneStyles[tone],
active && 'bg-sky-300/34 text-sky-50 ring-sky-100/85',
className,
)}
>
{children}
</button>
);
};

102
src/shared/ui/Dropdown.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { cn } from '@/shared/lib/cn';
interface DropdownProps {
trigger: ReactNode;
children: ReactNode;
align?: 'left' | 'right';
}
interface DropdownItemProps {
children: ReactNode;
href?: string;
onClick?: () => void;
danger?: boolean;
}
export const Dropdown = ({ trigger, children, align = 'right' }: DropdownProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('keydown', onEscape);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('keydown', onEscape);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
{trigger}
</button>
{open ? (
<div
onClick={() => setOpen(false)}
className={cn(
'absolute top-[calc(100%+0.5rem)] z-30 min-w-44 rounded-xl border border-white/15 bg-slate-950/95 p-1 shadow-2xl shadow-slate-950/70',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{children}
</div>
) : null}
</div>
);
};
export const DropdownItem = ({
children,
href,
onClick,
danger = false,
}: DropdownItemProps) => {
const className = cn(
'flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors',
danger
? 'text-rose-200 hover:bg-rose-300/20'
: 'text-white/90 hover:bg-white/10 hover:text-white',
);
if (href) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return (
<button type="button" onClick={onClick} className={className}>
{children}
</button>
);
};

View File

@@ -0,0 +1,26 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/shared/lib/cn';
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
elevated?: boolean;
}
export const GlassCard = ({
elevated = false,
className,
children,
...props
}: GlassCardProps) => {
return (
<div
{...props}
className={cn(
'rounded-2xl border border-white/20 bg-slate-950/35 backdrop-blur-xl',
elevated && 'shadow-[0_24px_60px_rgba(15,23,42,0.42)]',
className,
)}
>
{children}
</div>
);
};

97
src/shared/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,97 @@
'use client';
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { cn } from '@/shared/lib/cn';
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
interface ModalProps {
isOpen: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
export const Modal = ({
isOpen,
title,
description,
onClose,
children,
footer,
}: ModalProps) => {
const reducedMotion = useReducedMotion();
useEffect(() => {
if (!isOpen) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
<button
type="button"
aria-label="모달 닫기"
onClick={onClose}
className="absolute inset-0 bg-slate-950/78 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="custom-entry-modal-title"
className={cn(
'relative z-10 w-full max-w-3xl overflow-hidden rounded-3xl border border-white/20 bg-slate-950/92 shadow-[0_30px_100px_rgba(2,6,23,0.65)]',
reducedMotion
? 'transition-none'
: 'transition-transform duration-300 ease-out motion-reduce:transition-none',
)}
>
<header className="border-b border-white/12 px-6 py-5 sm:px-7">
<div className="flex items-start justify-between gap-4">
<div>
<h2 id="custom-entry-modal-title" className="text-lg font-semibold text-white">
{title}
</h2>
{description ? (
<p className="mt-1 text-sm text-white/65">{description}</p>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/20 px-2.5 py-1.5 text-xs text-white/80 transition hover:bg-white/10 hover:text-white"
>
</button>
</div>
</header>
<div className="max-h-[68vh] overflow-y-auto px-6 py-5 sm:px-7">{children}</div>
{footer ? (
<footer className="border-t border-white/12 bg-slate-900/80 px-6 py-4 sm:px-7">{footer}</footer>
) : null}
</div>
</div>
);
};

36
src/shared/ui/Tabs.tsx Normal file
View File

@@ -0,0 +1,36 @@
'use client';
import { cn } from '@/shared/lib/cn';
export interface TabOption {
value: string;
label: string;
}
interface TabsProps {
value: string;
options: TabOption[];
onChange: (value: string) => void;
}
export const Tabs = ({ value, options, onChange }: TabsProps) => {
return (
<div className="inline-flex w-full rounded-xl bg-white/6 p-1 ring-1 ring-white/15">
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 motion-reduce:transition-none',
option.value === value
? 'bg-sky-300/22 text-sky-100'
: 'text-white/65 hover:bg-white/8 hover:text-white/90',
)}
>
{option.label}
</button>
))}
</div>
);
};

68
src/shared/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,68 @@
'use client';
import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '@/shared/lib/cn';
interface ToastPayload {
title: string;
description?: string;
}
interface ToastItem extends ToastPayload {
id: number;
}
interface ToastContextValue {
pushToast: (payload: ToastPayload) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider = ({ children }: { children: ReactNode }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const pushToast = useCallback((payload: ToastPayload) => {
const id = Date.now() + Math.floor(Math.random() * 10000);
setToasts((current) => [...current, { id, ...payload }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 2400);
}, []);
const value = useMemo(() => ({ pushToast }), [pushToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-[min(92vw,340px)] flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
'rounded-xl border border-white/15 bg-slate-950/92 px-4 py-3 text-sm text-white shadow-lg shadow-slate-950/60',
'animate-[toast-in_180ms_ease-out] motion-reduce:animate-none',
)}
>
<p className="font-medium">{toast.title}</p>
{toast.description ? (
<p className="mt-1 text-xs text-white/70">{toast.description}</p>
) : null}
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};

7
src/shared/ui/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './Button';
export * from './Chip';
export * from './Dropdown';
export * from './GlassCard';
export * from './Modal';
export * from './Tabs';
export * from './Toast';