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 ? (
-

+
) : /* 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 && (
-

)}
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 (
+
+
+ {/* 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",
+ },
+ },
+ },
};