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:
7
src/shared/config/settingsOptions.ts
Normal file
7
src/shared/config/settingsOptions.ts
Normal 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
3
src/shared/lib/cn.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const cn = (...classes: Array<string | false | null | undefined>) => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
};
|
||||
23
src/shared/lib/useReducedMotion.ts
Normal file
23
src/shared/lib/useReducedMotion.ts
Normal 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;
|
||||
};
|
||||
@@ -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
42
src/shared/ui/Chip.tsx
Normal 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
102
src/shared/ui/Dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
src/shared/ui/GlassCard.tsx
Normal file
26
src/shared/ui/GlassCard.tsx
Normal 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
97
src/shared/ui/Modal.tsx
Normal 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
36
src/shared/ui/Tabs.tsx
Normal 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
68
src/shared/ui/Toast.tsx
Normal 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
7
src/shared/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user