diff --git a/next.config.ts b/next.config.ts index 6483e5d..1d22cbf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,9 @@ const nextConfig: NextConfig = { allowedDevOrigins: ["10.250.174.185", "10.250.203.166"], output: "export", trailingSlash: true, + images: { + unoptimized: true, // static export 환경에서 next/image 사용 시 필요 + }, }; export default nextConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f93633..3adf359 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) + baseline-browser-mapping: + specifier: ^2.10.0 + version: 2.10.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) diff --git a/src/index.css b/src/index.css index 2dea895..70f53a1 100644 --- a/src/index.css +++ b/src/index.css @@ -244,3 +244,37 @@ body { .shadow-lg { box-shadow: var(--shadow-lg); } + +@layer utilities { + /* 프로그레스 바 빗살무늬 배경 */ + .bg-stripes { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; /* 빗살무늬 크기 조절 */ + } + + /* 프로그레스 바 빗살무늬 애니메이션 */ + @keyframes progress-stripes { + 0% { background-position: 1rem 0; } + 100% { background-position: 0 0; } + } + .animate-progress-stripes { + animation: progress-stripes 1s linear infinite; + } + + /* 스켈레톤 로더 애니메이션 */ + @keyframes shimmer { + 100% { transform: translateX(100%); } + } + .animate-shimmer { + animation: shimmer 1.5s infinite; + } +} \ No newline at end of file diff --git a/src/shared/ui/avatars/avatars.tsx b/src/shared/ui/avatars/avatars.tsx index e726703..122e873 100644 --- a/src/shared/ui/avatars/avatars.tsx +++ b/src/shared/ui/avatars/avatars.tsx @@ -1,4 +1,5 @@ import React from "react"; +import Image from "next/image"; // 1. 공통 아이콘 Import 추가 import { UserSolidIcon } from "../icons"; @@ -124,11 +125,7 @@ export const Avatar = ({
{/* 1. 이미지 우선 렌더링 */} {src ? ( - {name + {name ) : /* 2. 이미지가 없으면 이름 이니셜 */ name ? ( {getInitial(name)} diff --git a/src/shared/ui/lists/lists.tsx b/src/shared/ui/lists/lists.tsx index 2573835..ba2cfd4 100644 --- a/src/shared/ui/lists/lists.tsx +++ b/src/shared/ui/lists/lists.tsx @@ -1,4 +1,5 @@ import React from "react"; +import Image from "next/image"; // 공통 아이콘 Import 추가 import { ChevronRightIcon, InboxIcon } from "../icons"; @@ -169,10 +170,12 @@ export const ListItem = ({ {/* 썸네일 이미지 */} {imageUrl && ( - {title} )} diff --git a/src/shared/ui/progress/progress.stories.tsx b/src/shared/ui/progress/progress.stories.tsx new file mode 100644 index 0000000..421babf --- /dev/null +++ b/src/shared/ui/progress/progress.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { ProgressBar, CircularProgress, Spinner, Skeleton } from "./progress"; + +// ========================================== +// Meta +// ========================================== +const meta = { + title: "Shared/UI/Progress", + component: ProgressBar, + parameters: { + layout: "padded", + componentSubtitle: "시스템의 진행 상태나 로딩 상태를 시각적으로 보여주는 컴포넌트들", + }, + tags: ["autodocs"], + argTypes: { + value: { + control: { type: "range", min: 0, max: 100 }, + description: "진행률 (0~100)", + }, + variant: { + control: "select", + options: ["primary", "success", "warning", "error"], + description: "프로그레스 바의 색상 테마", + }, + size: { + control: "select", + options: ["thin", "medium", "thick"], + description: "프로그레스 바의 두께", + }, + hasStripes: { control: "boolean", description: "빗살무늬 패턴 적용 여부" }, + isAnimated: { control: "boolean", description: "빗살무늬 애니메이션 적용 여부" }, + labelPosition: { + control: "select", + options: ["none", "top", "inside"], + description: "라벨(퍼센트) 표시 위치", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ========================================== +// ProgressBar Stories +// ========================================== +export const DefaultBar: Story = { + args: { + value: 45, + variant: "primary", + size: "medium", + hasStripes: false, + isAnimated: false, + labelPosition: "none", + }, +}; + +export const StripedAnimatedBar: Story = { + args: { + value: 75, + variant: "primary", + size: "thick", + hasStripes: true, + isAnimated: true, + labelPosition: "inside", + }, +}; + +// ========================================== +// Circular Progress +// ========================================== +export const Circular: StoryObj = { + render: () => ( +
+ + + +
+ ), +}; + +// ========================================== +// Spinner +// ========================================== +export const LoadingSpinner: StoryObj = { + render: () => ( +
+ + + +
+ ), +}; + +// ========================================== +// Skeleton Loader +// ========================================== +export const SkeletonLoader: StoryObj = { + render: () => ( +
+
+

🏗️ Card Skeleton Example

+
+ {/* Image placeholder */} + + +
+ {/* Title */} + + {/* Lines */} + + +
+ + {/* Avatar + Meta */} +
+ +
+ + +
+
+
+
+
+ ), +}; diff --git a/src/shared/ui/progress/progress.tsx b/src/shared/ui/progress/progress.tsx new file mode 100644 index 0000000..e835aaa --- /dev/null +++ b/src/shared/ui/progress/progress.tsx @@ -0,0 +1,189 @@ +import React from "react"; + +// ========================================== +// Progress Bar +// ========================================== +export interface ProgressBarProps { + value: number; // 0 ~ 100 + variant?: "primary" | "success" | "warning" | "error"; + size?: "thin" | "medium" | "thick"; + hasStripes?: boolean; + isAnimated?: boolean; + labelPosition?: "none" | "top" | "inside"; + className?: string; +} + +export const ProgressBar = ({ + value, + variant = "primary", + size = "medium", + hasStripes = false, + isAnimated = false, + labelPosition = "none", + className = "", +}: ProgressBarProps) => { + const clampedValue = Math.min(100, Math.max(0, value)); + + const variantClasses = { + primary: "bg-[color:var(--color-primary-800,#004A9C)]", + success: "bg-[color:var(--color-success-500,#10A259)]", + warning: "bg-[color:var(--color-warning-500,#F59E0B)]", + error: "bg-[color:var(--color-error-500,#EF4444)]", + }; + + const sizeClasses = { + thin: "h-[4px] rounded-[2px]", + medium: "h-[8px] rounded-[4px]", + thick: "h-[12px] rounded-[6px]", + }; + + const fillClasses = [ + "h-full rounded-full transition-all duration-300 ease-in-out relative overflow-hidden", + variantClasses[variant], + hasStripes ? "bg-stripes bg-[length:1rem_1rem]" : "", // 👈 bg-[length:1rem_1rem] 추가 + isAnimated && hasStripes ? "animate-progress-stripes" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+ {/* Top Label */} + {labelPosition === "top" && ( +
+ 진행률 + {clampedValue}% +
+ )} + + {/* Track */} +
+ {/* Fill */} +
+ {/* Inside Label */} + {labelPosition === "inside" && size === "thick" && ( +
+ {clampedValue}% +
+ )} +
+
+
+ ); +}; + +// ========================================== +// 14-2. Circular Progress +// ========================================== +export interface CircularProgressProps { + value: number; // 0 ~ 100 + size?: "small" | "medium" | "large"; + showLabel?: boolean; + className?: string; +} + +export const CircularProgress = ({ + value, + size = "medium", + showLabel = true, + className = "", +}: CircularProgressProps) => { + const clampedValue = Math.min(100, Math.max(0, value)); + + const sizeConfig = { + small: { svgSize: 32, strokeWidth: 4, fontSize: "text-[12px]" }, + medium: { svgSize: 64, strokeWidth: 6, fontSize: "text-[16px]" }, + large: { svgSize: 96, strokeWidth: 8, fontSize: "text-[20px]" }, + }; + + const { svgSize, strokeWidth, fontSize } = sizeConfig[size]; + const radius = (svgSize - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const dashoffset = circumference - (clampedValue / 100) * circumference; + + return ( +
+ + {/* Background Circle */} + + {/* Progress Circle */} + + + {/* Center Label */} + {showLabel && ( + + {clampedValue}% + + )} +
+ ); +}; + +// ========================================== +// 14-3. Spinner +// ========================================== +export interface SpinnerProps { + size?: "small" | "medium" | "large"; + className?: string; +} + +export const Spinner = ({ size = "medium", className = "" }: SpinnerProps) => { + const sizeClasses = { + small: "w-[16px] h-[16px] border-[2px]", + medium: "w-[24px] h-[24px] border-[3px]", + large: "w-[48px] h-[48px] border-[4px]", + }; + + return ( +
+ ); +}; + +// ========================================== +// 14-4. Skeleton Loader +// ========================================== +export interface SkeletonProps { + shape?: "text" | "title" | "circle" | "rectangle"; + className?: string; +} + +export const Skeleton = ({ shape = "text", className = "" }: SkeletonProps) => { + const shapeClasses = { + text: "w-full h-[12px] rounded-[4px]", + title: "w-[60%] h-[20px] rounded-[6px]", + circle: "w-[40px] h-[40px] rounded-full flex-shrink-0", + rectangle: "w-full aspect-video rounded-[8px]", + }; + + return ( +
+
+
+ ); +}; diff --git a/tailwind.config.js b/tailwind.config.js index 204c9b4..efc7623 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,28 @@ /** @type {import('tailwindcss').Config} */ export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + // 프로그레스 바 빗살무늬 + backgroundImage: { + stripes: + "linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent)", + }, + // 애니메이션 키프레임 + keyframes: { + "progress-stripes": { + "0%": { backgroundPosition: "1rem 0" }, + "100%": { backgroundPosition: "0 0" }, + }, + shimmer: { + "100%": { transform: "translateX(100%)" }, + }, + }, + // 애니메이션 클래스 생성 + animation: { + "progress-stripes": "progress-stripes 1s linear infinite", + shimmer: "shimmer 1.5s infinite", + }, + }, + }, };