From ea2f6d7e5d79b7a9c73eea57753a1842d570dfa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A5=AD=5Cleevi?= Date: Mon, 9 Feb 2026 00:46:26 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=95=A0=20=EC=9D=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=83=81=EC=84=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EB=B0=9C=20=EB=B0=8F=20=EC=BC=80=EB=B0=A5?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Card/TaskDetailCard.module.css | 159 +++++++ .../Card/TaskDetailCard.stories.tsx | 388 ++++++++++++++++++ src/components/Card/TaskDetailCard.tsx | 218 ++++++++++ src/components/KebabMenu/KebabMenu.module.css | 67 +++ src/components/KebabMenu/KebabMenu.tsx | 65 +++ 5 files changed, 897 insertions(+) create mode 100644 src/components/Card/TaskDetailCard.module.css create mode 100644 src/components/Card/TaskDetailCard.stories.tsx create mode 100644 src/components/Card/TaskDetailCard.tsx create mode 100644 src/components/KebabMenu/KebabMenu.module.css create mode 100644 src/components/KebabMenu/KebabMenu.tsx diff --git a/src/components/Card/TaskDetailCard.module.css b/src/components/Card/TaskDetailCard.module.css new file mode 100644 index 0000000..43f3111 --- /dev/null +++ b/src/components/Card/TaskDetailCard.module.css @@ -0,0 +1,159 @@ +.container { + width: 100%; + max-width: 780px; + background: var(--color-background-primary); + border-radius: 12px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.header { + display: flex; + align-items: flex-start; + gap: 12px; + position: relative; +} + +.closeButton { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--color-icon-primary); + transition: color 0.2s; +} + +.closeButton:hover { + color: var(--color-text-secondary); +} + +.title { + flex: 1; + margin: 0 40px; + font-size: 20px; + font-weight: 600; + color: var(--color-text-tertiary); + line-height: 1.4; +} + +.kebabWrapper { + position: absolute; + top: 0; + right: 0; +} + +.assigneeSection { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-background-tertiary); +} + +.assigneeName { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); +} + +.metaSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.metaRow { + display: flex; + align-items: center; + gap: 8px; +} + +.metaLabel { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + min-width: 80px; +} + +.metaValue { + font-size: 14px; + color: var(--color-text-primary); +} + +.descriptionSection { + padding: 16px 0; + border-top: 1px solid var(--color-background-tertiary); + border-bottom: 1px solid var(--color-background-tertiary); +} + +.description { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: var(--color-text-primary); + white-space: pre-wrap; + word-break: break-word; +} + +.actionSection { + display: flex; + justify-content: flex-end; +} + +.commentSection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.commentTitle { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text-tertiary); +} + +.commentCount { + color: var(--color-brand-primary); +} + +.commentInputWrapper { + margin-bottom: 8px; +} + +.commentList { + display: flex; + flex-direction: column; + gap: 16px; +} + +@media (max-width: 767px) { + .container { + padding: 16px; + gap: 20px; + } + + .title { + font-size: 18px; + margin: 0 32px; + } + + .metaLabel { + min-width: 70px; + font-size: 13px; + } + + .metaValue { + font-size: 13px; + } +} diff --git a/src/components/Card/TaskDetailCard.stories.tsx b/src/components/Card/TaskDetailCard.stories.tsx new file mode 100644 index 0000000..b25fefc --- /dev/null +++ b/src/components/Card/TaskDetailCard.stories.tsx @@ -0,0 +1,388 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { fn } from 'storybook/test'; +import TaskDetailCard from './TaskDetailCard'; + +const meta = { + title: 'Components/Card/TaskDetailCard', + component: TaskDetailCard, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +TaskDetailCard는 할 일 상세 정보를 표시하는 카드 컴포넌트입니다. + +- 할 일 제목, 담당자, 시작 날짜, 반복 설정, 본문 내용을 표시합니다. +- 완료하기/완료 취소하기 버튼으로 할 일 상태를 변경할 수 있습니다. +- KebabMenu를 통해 수정/삭제 액션을 제공합니다. +- 댓글 목록과 댓글 입력 기능을 포함합니다. +- API 응답 구조(Task, Comment)와 일치하는 Props를 받습니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + id: { + control: 'number', + description: '할 일 ID', + }, + name: { + control: 'text', + description: '할 일 제목', + }, + description: { + control: 'text', + description: '할 일 상세 내용', + }, + date: { + control: 'text', + description: '시작 날짜 (ISO 8601 형식)', + }, + frequency: { + control: 'select', + options: ['ONCE', 'DAILY', 'WEEKLY', 'MONTHLY'], + description: '반복 설정', + }, + writer: { + control: 'object', + description: '작성자 정보', + }, + doneAt: { + control: 'text', + description: '완료 시각 (null이면 미완료)', + }, + comments: { + control: 'object', + description: '댓글 목록', + }, + onComplete: { + action: 'complete', + description: '완료하기 버튼 클릭', + }, + onEdit: { + action: 'edit', + description: '수정하기 클릭', + }, + onDelete: { + action: 'delete', + description: '삭제하기 클릭', + }, + onClose: { + action: 'close', + description: '닫기 버튼 클릭', + }, + onCommentSubmit: { + action: 'comment-submit', + description: '댓글 작성', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock 데이터 +const mockWriter = { + id: 1, + nickname: '안해나', + image: null, +}; + +const mockComments = [ + { + id: 1, + content: '법인 설립 서류는 관련 법률 정문 드리겠습니다. https://www.codeit.kr', + createdAt: '2024-07-29T15:30:00Z', + updatedAt: '2024-07-29T15:30:00Z', + taskId: 1, + userId: 1, + user: { + id: 1, + nickname: '안해나', + image: null, + }, + }, + { + id: 2, + content: '혹시 관련해서 미팅 오늘 중으로 가능하신가요?', + createdAt: '2024-07-25T10:00:00Z', + updatedAt: '2024-07-25T10:00:00Z', + taskId: 1, + userId: 2, + user: { + id: 2, + nickname: '김대해', + image: null, + }, + }, + { + id: 3, + content: '법인 설립 비용 관련해서 예상 레퍼런스도 제공해달라고 들었는데 제공받은 걸 같이요?', + createdAt: '2024-07-25T09:30:00Z', + updatedAt: '2024-07-25T09:30:00Z', + taskId: 1, + userId: 3, + user: { + id: 3, + nickname: '이연지', + image: null, + }, + }, +]; + +// 기본 (미완료) +export const Default: Story = { + args: { + id: 1, + name: '법인 설립 비용 안내 드리기', + description: + '필수 정보 10분 입력하면 3일 안에 법인 설립이 완료되는 법인 설립 서비스의 장점에 대해 상세하게 설명드리기', + date: '2024-07-29T15:30:00Z', + frequency: 'DAILY', + writer: mockWriter, + doneAt: null, + comments: mockComments, + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '미완료 상태의 할 일 카드입니다. 완료하기 버튼이 표시됩니다.', + }, + }, + }, +}; + +// 완료 상태 +export const Completed: Story = { + args: { + id: 1, + name: '법인 설립 비용 안내 드리기', + description: + '필수 정보 10분 입력하면 3일 안에 법인 설립이 완료되는 법인 설립 서비스의 장점에 대해 상세하게 설명드리기', + date: '2024-07-29T15:30:00Z', + frequency: 'DAILY', + writer: mockWriter, + doneAt: '2024-07-30T10:00:00Z', + comments: mockComments, + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '완료된 할 일 카드입니다. 완료 취소하기 버튼이 표시됩니다.', + }, + }, + }, +}; + +// 한번 반복 +export const FrequencyOnce: Story = { + args: { + id: 2, + name: '커피 머신 고장 신고하기', + description: '1층 커피 머신에서 물이 샙니다.', + date: '2024-07-29T09:00:00Z', + frequency: 'ONCE', + writer: mockWriter, + doneAt: null, + comments: [], + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '한번만 실행되는 할 일입니다.', + }, + }, + }, +}; + +// 주 반복 +export const FrequencyWeekly: Story = { + args: { + id: 3, + name: '주간 회의 준비', + description: '매주 월요일 회의 자료 준비', + date: '2024-11-14T10:00:00Z', + frequency: 'WEEKLY', + writer: mockWriter, + doneAt: null, + comments: [], + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '주 단위로 반복되는 할 일입니다.', + }, + }, + }, +}; + +// 월 반복 +export const FrequencyMonthly: Story = { + args: { + id: 4, + name: '월간 보고서 작성', + description: '매월 말일 제출', + date: '2024-11-14T10:00:00Z', + frequency: 'MONTHLY', + writer: mockWriter, + doneAt: null, + comments: [], + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '월 단위로 반복되는 할 일입니다.', + }, + }, + }, +}; + +// 댓글 없음 +export const NoComments: Story = { + args: { + id: 5, + name: '새로운 할 일', + description: '아직 댓글이 없습니다.', + date: '2024-07-29T15:30:00Z', + frequency: 'ONCE', + writer: mockWriter, + doneAt: null, + comments: [], + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '댓글이 없는 할 일 카드입니다.', + }, + }, + }, +}; + +// 긴 내용 +export const LongContent: Story = { + args: { + id: 6, + name: '매우 긴 제목을 가진 할 일입니다. 이렇게 긴 제목도 잘 표시되는지 확인이 필요합니다.', + description: `이것은 매우 긴 설명입니다. + +여러 줄에 걸쳐 작성된 내용도 제대로 표시되는지 확인해야 합니다. + +1. 첫 번째 항목 +2. 두 번째 항목 +3. 세 번째 항목 + +이렇게 긴 텍스트가 어떻게 보이는지 확인하는 것이 중요합니다.`, + date: '2024-07-29T15:30:00Z', + frequency: 'DAILY', + writer: mockWriter, + doneAt: null, + comments: mockComments, + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + parameters: { + docs: { + description: { + story: '긴 제목과 본문 내용을 가진 할 일 카드입니다.', + }, + }, + }, +}; + +// 모든 반복 타입 비교 +export const AllFrequencies: Story = { + args: { + id: 1, + name: '반복 타입 비교', + description: '반복 설정 비교용', + date: '2024-07-29T15:30:00Z', + frequency: 'ONCE', + writer: mockWriter, + doneAt: null, + comments: [], + onComplete: fn(), + onEdit: fn(), + onDelete: fn(), + onClose: fn(), + onCommentSubmit: fn(), + }, + render: () => { + const frequencies: Array<'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'> = [ + 'ONCE', + 'DAILY', + 'WEEKLY', + 'MONTHLY', + ]; + + return ( +
+ {frequencies.map((freq) => ( + + ))} +
+ ); + }, + parameters: { + docs: { + description: { + story: '모든 반복 설정 타입을 비교할 수 있습니다.', + }, + }, + }, +}; diff --git a/src/components/Card/TaskDetailCard.tsx b/src/components/Card/TaskDetailCard.tsx new file mode 100644 index 0000000..8728f3d --- /dev/null +++ b/src/components/Card/TaskDetailCard.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import styles from './TaskDetailCard.module.css'; +import KebabMenu from '@/components/KebabMenu/KebabMenu'; +import ProfileImage from '@/components/profile-img/ProfileImage'; +import FilledRoundButton from '@/components/Button/domain/FilledRoundButton/FilledRoundButton'; +import CommentCard from '@/components/comment/CommentCard'; +import CommentInput from '@/components/input/CommentInput'; + +import calendarIcon from '@/assets/icons/calender/calenderBig.svg'; +import repeatIcon from '@/assets/icons/repeat/repeatBig.svg'; +import closeIcon from '@/assets/icons/xMark/xMarkBig.svg'; + +/* API 응답 구조 - 작성자 정보 */ +interface Writer { + id: number; + nickname: string; + image: string | null; +} + +/* API 응답 구조 - 댓글 정보 */ +interface Comment { + id: number; + content: string; + createdAt: string; + updatedAt: string; + taskId: number; + userId: number; + user: { + id: number; + nickname: string; + image: string | null; + }; +} + +interface TaskDetailCardProps { + id: number; + name: string; + description: string; + date: string; + frequency: 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; + writer: Writer; + doneAt: string | null; + comments: Comment[]; + onComplete?: () => void; + onEdit?: () => void; + onDelete?: () => void; + onClose?: () => void; + onCommentSubmit?: (content: string) => void; +} + +const FREQUENCY_LABEL: Record = { + ONCE: '한번', + DAILY: '매일', + WEEKLY: '주 반복', + MONTHLY: '월 반복', +}; + +function formatDate(dateString: string): string { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hours = date.getHours(); + const minutes = date.getMinutes(); + const period = hours >= 12 ? '오후' : '오전'; + const displayHours = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours; + + return `${year}년 ${month}월 ${day}일 ${period} ${displayHours}:${minutes.toString().padStart(2, '0')}`; +} + +function formatCommentDate(dateString: string): string { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + + return `${year}. ${month}. ${day}`; +} + +/** + * TaskDetailCard + * + * 할 일 상세 정보를 표시하는 카드 컴포넌트입니다. + * + * @remarks + * - API 응답 구조(Task, Comment)와 일치하는 Props를 받습니다. + * - frequency 값을 컴포넌트 내부에서 한글로 변환합니다 (ONCE → "한번"). + * - ISO 8601 날짜를 한국어 형식으로 자동 변환합니다. + * - 완료 상태(doneAt)에 따라 버튼 텍스트가 자동으로 변경됩니다. + * - KebabMenu를 통해 수정/삭제 액션을 제공합니다. + * + * @example + * ```tsx + * completeMutation.mutate(task.id)} + * onEdit={() => router.push(`/edit`)} + * onDelete={() => deleteMutation.mutate(task.id)} + * /> + * ``` + */ +export default function TaskDetailCard({ + name, + writer, + date, + frequency, + description, + comments, + doneAt, + onComplete, + onEdit, + onDelete, + onClose, + onCommentSubmit, +}: TaskDetailCardProps) { + const [commentValue, setCommentValue] = useState(''); + + const isCompleted = !!doneAt; + const frequencyLabel = FREQUENCY_LABEL[frequency]; + const formattedDate = formatDate(date); + + const handleCommentSubmit = () => { + if (!commentValue.trim()) return; + onCommentSubmit?.(commentValue); + setCommentValue(''); + }; + + return ( +
+
+ + +

{name}

+ +
+ onEdit?.()} onDelete={() => onDelete?.()} /> +
+
+ +
+ + {writer.nickname} +
+ +
+
+ + 시작 날짜 + {formattedDate} +
+ +
+ + 반복 설정 + {frequencyLabel} +
+
+ +
+

{description}

+
+ +
+ + {isCompleted ? '완료 취소하기' : '완료하기'} + +
+ +
+

+ 댓글 {comments.length} +

+ +
+ setCommentValue(e.target.value)} + onSubmit={handleCommentSubmit} + placeholder="댓글을 입력해주세요" + /> +
+ +
+ {comments.map((comment) => ( + + } + /> + ))} +
+
+
+ ); +} diff --git a/src/components/KebabMenu/KebabMenu.module.css b/src/components/KebabMenu/KebabMenu.module.css new file mode 100644 index 0000000..942a023 --- /dev/null +++ b/src/components/KebabMenu/KebabMenu.module.css @@ -0,0 +1,67 @@ +.container { + position: relative; + display: inline-block; +} + +.trigger { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--color-icon-primary); + transition: color 0.2s; +} + +.trigger:hover { + color: var(--color-text-secondary); +} + +.icon { + font-size: 20px; + line-height: 1; + user-select: none; +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 120px; + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + overflow: hidden; + z-index: 100; +} + +.menuItem { + display: block; + width: 100%; + padding: 12px 16px; + text-align: left; + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + background: none; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} + +.menuItem:hover { + background-color: var(--color-background-secondary); +} + +.menuItem:active { + background-color: var(--color-background-tertiary); +} + +.menuItem + .menuItem { + border-top: 1px solid var(--color-background-tertiary); +} diff --git a/src/components/KebabMenu/KebabMenu.tsx b/src/components/KebabMenu/KebabMenu.tsx new file mode 100644 index 0000000..b0370eb --- /dev/null +++ b/src/components/KebabMenu/KebabMenu.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import styles from './KebabMenu.module.css'; + +interface KebabMenuProps { + onEdit: () => void; + onDelete: () => void; +} + +export default function KebabMenu({ onEdit, onDelete }: KebabMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleEdit = () => { + onEdit(); + setIsOpen(false); + }; + + const handleDelete = () => { + onDelete(); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ + +
+ )} +
+ ); +} From 574e499fa96cae1e7b44845ec29e2eda70c83c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A5=AD=5Cleevi?= Date: Mon, 9 Feb 2026 02:07:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=95=A0=20=EC=9D=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=AA=A9=EB=A1=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Card/TaskCard.module.css | 40 +++++ src/components/Card/TaskCard.stories.tsx | 184 +++++++++++++++++++++++ src/components/Card/TaskCard.tsx | 42 ++++++ 3 files changed, 266 insertions(+) create mode 100644 src/components/Card/TaskCard.module.css create mode 100644 src/components/Card/TaskCard.stories.tsx create mode 100644 src/components/Card/TaskCard.tsx diff --git a/src/components/Card/TaskCard.module.css b/src/components/Card/TaskCard.module.css new file mode 100644 index 0000000..6db349f --- /dev/null +++ b/src/components/Card/TaskCard.module.css @@ -0,0 +1,40 @@ +.card { + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + height: 54px; + + padding: 0 20px; + border: 1px solid var(--color-border-primary); + border-radius: 12px; + background: var(--color-background-primary); + + cursor: pointer; + user-select: none; + + transition: transform 0.2s ease; +} + +.card:hover { + transform: scale(1.01); +} + +.card:active { + transform: scale(0.99); +} + +.label { + font-size: 14px; + font-weight: 600; + line-height: 17px; + color: var(--color-text-primary); +} + +.count { + font-size: 14px; + font-weight: 700; + line-height: 17px; + color: var(--color-brand-primary); +} diff --git a/src/components/Card/TaskCard.stories.tsx b/src/components/Card/TaskCard.stories.tsx new file mode 100644 index 0000000..5eee9bd --- /dev/null +++ b/src/components/Card/TaskCard.stories.tsx @@ -0,0 +1,184 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { fn } from 'storybook/test'; +import TaskCard from './TaskCard'; + +const meta = { + title: 'Components/Card/TaskCard', + component: TaskCard, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +TaskCard는 PC 환경에서 할 일 목록을 카드 형태로 표시하는 컴포넌트입니다. + +- 할 일 이름(label)과 개수(count)를 표시합니다. +- 모바일/태블릿에서는 동일한 데이터를 Chip 컴포넌트로 표현합니다. +- hover 시 미묘한 scale 효과가 적용됩니다. +- 높이는 54px로 고정되며, 너비는 부모 컨테이너를 따릅니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + label: { + control: 'text', + description: '할 일 이름', + }, + count: { + control: 'number', + description: '할 일 개수', + }, + onClick: { + action: 'clicked', + description: '클릭 이벤트 핸들러', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 기본 +export const Default: Story = { + args: { + label: '법인 설립', + count: 12, + onClick: fn(), + }, + parameters: { + docs: { + description: { + story: '기본 TaskCard입니다.', + }, + }, + }, +}; + +// 다양한 개수 +export const SmallCount: Story = { + args: { + label: '변경 등기', + count: 2, + onClick: fn(), + }, + parameters: { + docs: { + description: { + story: '작은 개수(2개)의 TaskCard입니다.', + }, + }, + }, +}; + +export const LargeCount: Story = { + args: { + label: '법인 폐업', + count: 158, + onClick: fn(), + }, + parameters: { + docs: { + description: { + story: '큰 개수(158개)의 TaskCard입니다.', + }, + }, + }, +}; + +// 긴 텍스트 +export const LongText: Story = { + args: { + label: '매우 긴 할 일 이름입니다 이렇게 길어도 잘 표시될까요', + count: 5, + onClick: fn(), + }, + parameters: { + docs: { + description: { + story: '긴 텍스트를 가진 TaskCard입니다.', + }, + }, + }, +}; + +// 여러 개 나열 +export const Multiple: Story = { + args: { + label: '법인 설립', + count: 3, + }, + render: () => { + const tasks = [ + { label: '법인 설립', count: 3 }, + { label: '변경 등기', count: 2 }, + { label: '법인 폐업', count: 5 }, + { label: '상표 등록', count: 1 }, + ]; + + return ( +
+ {tasks.map((task, index) => ( + + ))} +
+ ); + }, + parameters: { + docs: { + description: { + story: '여러 개의 TaskCard를 나열한 예시입니다. (내가 한 일 페이지)', + }, + }, + }, +}; + +// 너비 비교 +// ✅ 수정 +export const WidthComparison: Story = { + args: { + label: '법인 설립', + count: 12, + }, + render: () => { + return ( +
+
+

270px (기본)

+
+ +
+
+ +
+

350px

+
+ +
+
+ +
+

200px

+
+ +
+
+
+ ); + }, + parameters: { + docs: { + description: { + story: '부모 컨테이너 너비에 따라 카드 너비가 변하는 것을 보여줍니다.', + }, + }, + }, +}; diff --git a/src/components/Card/TaskCard.tsx b/src/components/Card/TaskCard.tsx new file mode 100644 index 0000000..010665d --- /dev/null +++ b/src/components/Card/TaskCard.tsx @@ -0,0 +1,42 @@ +import styles from './TaskCard.module.css'; + +interface TaskCardProps { + label: string; + count: number; + onClick?: () => void; + className?: string; +} + +/** + * TaskCard + * + * PC 환경에서 할 일 목록을 카드 형태로 표시하는 컴포넌트입니다. + * + * @remarks + * - 모바일/태블릿에서는 Chip 컴포넌트가 대신 사용됩니다. + * - 높이는 54px로 고정되며, 너비는 부모 컨테이너를 따릅니다. + * - hover 시 미묘한 scale 효과가 적용됩니다. + * - selected, pressed 같은 복잡한 상태는 없습니다. + * + * @example + * ```tsx + *
+ * + *
+ * ``` + */ +export default function TaskCard({ label, count, onClick, className = '' }: TaskCardProps) { + const cardClassName = [styles.card, className].filter(Boolean).join(' '); + + return ( + + ); +} From 0788f4a9aeb78790f6307e7bc1cb9518179d5234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A5=AD=5Cleevi?= Date: Tue, 10 Feb 2026 12:12:32 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=95=84=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=B9=B4=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EB=B0=8F=20setting.json=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20css=20=EB=B9=84?= =?UTF-8?q?=ED=91=9C=EC=A4=80=20ignore=20=EB=B9=8C=EB=93=9C=EC=8B=9C?= =?UTF-8?q?=EC=97=90=20=EB=AC=B8=EC=A0=9C=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 7 +- .../Card/ArticleCard/ArticleCard.module.css | 166 ++++++++ .../Card/ArticleCard/ArticleCard.stories.tsx | 376 ++++++++++++++++++ .../Card/ArticleCard/ArticleCard.tsx | 108 +++++ .../Card/{ => TaskCard}/TaskCard.module.css | 0 .../Card/{ => TaskCard}/TaskCard.stories.tsx | 0 .../Card/{ => TaskCard}/TaskCard.tsx | 0 .../TaskDetailCard.module.css | 0 .../TaskDetailCard.stories.tsx | 0 .../{ => TaskDetailCard}/TaskDetailCard.tsx | 0 10 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 src/components/Card/ArticleCard/ArticleCard.module.css create mode 100644 src/components/Card/ArticleCard/ArticleCard.stories.tsx create mode 100644 src/components/Card/ArticleCard/ArticleCard.tsx rename src/components/Card/{ => TaskCard}/TaskCard.module.css (100%) rename src/components/Card/{ => TaskCard}/TaskCard.stories.tsx (100%) rename src/components/Card/{ => TaskCard}/TaskCard.tsx (100%) rename src/components/Card/{ => TaskDetailCard}/TaskDetailCard.module.css (100%) rename src/components/Card/{ => TaskDetailCard}/TaskDetailCard.stories.tsx (100%) rename src/components/Card/{ => TaskDetailCard}/TaskDetailCard.tsx (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8cf8e89..c7d55b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,10 @@ "source.fixAll.eslint": "explicit" }, - "eslint.useFlatConfig": true + "eslint.useFlatConfig": true, + + // CSS 린터 설정 + "css.lint.vendorPrefix": "ignore", + "scss.lint.vendorPrefix": "ignore", + "less.lint.vendorPrefix": "ignore" } diff --git a/src/components/Card/ArticleCard/ArticleCard.module.css b/src/components/Card/ArticleCard/ArticleCard.module.css new file mode 100644 index 0000000..15a5619 --- /dev/null +++ b/src/components/Card/ArticleCard/ArticleCard.module.css @@ -0,0 +1,166 @@ +.card { + display: flex; + gap: 16px; + padding: 20px; + background: var(--color-background-primary); + border: 1px solid var(--color-border-primary); + border-radius: 12px; + cursor: pointer; + transition: transform 0.2s ease; +} + +.card:hover { + transform: translateY(-2px); +} + +.card:active { + transform: translateY(0); +} + +.content { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + padding: 4px 8px; + background: var(--color-background-tertiary); + border-radius: 4px; + font-size: 12px; + font-weight: 600; + color: var(--color-brand-primary); +} + +.badge img { + filter: brightness(0) saturate(100%) invert(46%) sepia(94%) saturate(2667%) hue-rotate(207deg) + brightness(98%) contrast(96%); +} + +.title { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1.5; + color: var(--color-text-primary); + + /* 2줄 말줄임 (비표준이지만 모든 브라우저 지원) */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.preview { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--color-text-secondary); + + /* 2줄 말줄임 (비표준이지만 모든 브라우저 지원) */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--color-text-secondary); +} + +.writer { + font-weight: 500; +} + +.divider { + color: var(--color-text-disabled); +} + +.date { + font-weight: 400; +} + +.rightSection { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.imageWrapper { + flex-shrink: 0; + width: 120px; + height: 120px; + border-radius: 8px; + overflow: hidden; + background: var(--color-background-secondary); +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.like { + display: flex; + align-items: center; + gap: 4px; + margin-top: auto; + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); +} + +/* 태블릿 */ +@media (max-width: 1199px) { + .card { + padding: 16px; + } + + .imageWrapper { + width: 100px; + height: 100px; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .card { + padding: 16px; + gap: 12px; + } + + .title { + font-size: 14px; + } + + .preview { + font-size: 13px; + } + + .meta { + font-size: 12px; + } + + .like { + font-size: 12px; + } + + .imageWrapper { + width: 80px; + height: 80px; + } +} diff --git a/src/components/Card/ArticleCard/ArticleCard.stories.tsx b/src/components/Card/ArticleCard/ArticleCard.stories.tsx new file mode 100644 index 0000000..9819670 --- /dev/null +++ b/src/components/Card/ArticleCard/ArticleCard.stories.tsx @@ -0,0 +1,376 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { fn } from 'storybook/test'; +import ArticleCard from './ArticleCard'; + +const meta = { + title: 'Components/Card/ArticleCard', + component: ArticleCard, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +ArticleCard는 자유게시판 게시글을 카드 형태로 표시하는 컴포넌트입니다. + +- 게시글 제목, 작성자, 작성일, 좋아요 수를 표시합니다. +- 인기글일 경우 "인기" 뱃지가 표시됩니다. +- 이미지가 있으면 우측에 표시되고, 없으면 텍스트가 전체 공간을 차지합니다. +- content 필드는 현재 API에 없지만, 추후 추가를 대비해 optional로 구현했습니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + id: { + control: 'number', + description: '게시글 ID', + }, + title: { + control: 'text', + description: '게시글 제목', + }, + content: { + control: 'text', + description: '게시글 본문 (현재 API에 없음)', + }, + writer: { + control: 'object', + description: '작성자 정보', + }, + createdAt: { + control: 'text', + description: '작성일 (ISO 8601 형식)', + }, + likeCount: { + control: 'number', + description: '좋아요 수', + }, + image: { + control: 'text', + description: '첨부 이미지 URL', + }, + isBest: { + control: 'boolean', + description: '인기글 여부', + }, + onClick: { + action: 'clicked', + description: '클릭 이벤트 핸들러', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Mock 데이터 +const mockWriter = { + id: 1, + nickname: '우지윤', +}; + +// 기본 +export const Default: Story = { + args: { + id: 1, + title: '커피 머신 고장 신고합니다 🫠', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 24, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '기본 ArticleCard입니다. 이미지와 본문 미리보기가 없습니다. (전체 게시글: 523x156)', + }, + }, + }, +}; + +// 인기글 (베스트 게시글) +export const Best: Story = { + args: { + id: 2, + title: '커피 머신 고장 신고합니다 🫠', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 999, + isBest: true, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '인기글 뱃지가 표시됩니다. (베스트 게시글: 350x210)', + }, + }, + }, +}; + +// 이미지 있음 (전체 게시글) +export const WithImage: Story = { + args: { + id: 3, + title: '커피 머신 고장 신고합니다 🫠', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 24, + image: 'https://picsum.photos/400/400', + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '이미지가 우측에 표시됩니다. (전체 게시글: 523x156)', + }, + }, + }, +}; + +// 본문 미리보기 + 이미지 (베스트 게시글) +export const BestWithImageAndContent: Story = { + args: { + id: 4, + title: '커피 머신 고장 신고합니다 🫠', + content: + '오늘 아침 출근과 동시에 알게 된 사실... 1층 커피 머신에서 물만 나옵니다. (커피는 실종 😭)', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 999, + image: 'https://picsum.photos/400/400', + isBest: true, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '본문 미리보기와 이미지가 모두 표시됩니다. (베스트 게시글: 350x210)', + }, + }, + }, +}; + +// 본문 미리보기 (현재 API에 없음) +export const WithContent: Story = { + args: { + id: 5, + title: '커피 머신 고장 신고합니다 🫠', + content: + '오늘 아침 출근과 동시에 알게 된 사실... 1층 커피 머신에서 물만 나옵니다. (커피는 실종 😭) 점검 보냈으니 최대한 빨리 해결될 수 있도록 하겠습니다! 💪', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 24, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '본문 미리보기가 표시됩니다 (현재 API에는 없음). (전체 게시글: 523x156)', + }, + }, + }, +}; + +// 긴 제목 +export const LongTitle: Story = { + args: { + id: 6, + title: + '매우 긴 제목을 가진 게시글입니다. 이렇게 긴 제목도 2줄까지만 표시되고 나머지는 말줄임표로 처리됩니다. 확인해보세요!', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 5, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '긴 제목은 2줄까지만 표시되고 말줄임표로 처리됩니다.', + }, + }, + }, +}; + +// 좋아요 999+ +export const HighLikes: Story = { + args: { + id: 7, + title: '인기 폭발! 좋아요 1000개 돌파', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 1234, + isBest: true, + onClick: fn(), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + story: '좋아요가 1000개 이상이면 999+로 표시됩니다.', + }, + }, + }, +}; + +// 여러 개 나열 (전체 게시글) +export const MultipleAll: Story = { + args: { + id: 8, + title: '커피 머신 고장 신고합니다', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 24, + }, + render: () => { + const articles = [ + { + id: 1, + title: '커피 머신 고장 신고합니다 🫠', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 999, + image: 'https://picsum.photos/400/400', + }, + { + id: 2, + title: '점심 메뉴 추천 부탁드립니다', + writer: { id: 2, nickname: '김대해' }, + createdAt: '2024-07-24T14:30:00Z', + likeCount: 156, + }, + { + id: 3, + title: '회의실 예약 시스템 개선 제안', + writer: { id: 3, nickname: '이연지' }, + createdAt: '2024-07-23T10:15:00Z', + likeCount: 89, + image: 'https://picsum.photos/400/401', + }, + ]; + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ); + }, + parameters: { + docs: { + description: { + story: '여러 개의 ArticleCard를 나열한 예시입니다 (전체 게시글: 523x156).', + }, + }, + }, +}; + +// 여러 개 나열 (베스트 게시글) +export const MultipleBest: Story = { + args: { + id: 9, + title: '커피 머신 고장 신고합니다', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 999, + isBest: true, + }, + render: () => { + const articles = [ + { + id: 1, + title: '커피 머신 고장 신고합니다 🫠', + content: '오늘 아침 출근과 동시에 알게 된 사실... 1층 커피 머신에서 물만 나옵니다.', + writer: mockWriter, + createdAt: '2024-07-25T09:00:00Z', + likeCount: 999, + image: 'https://picsum.photos/400/400', + isBest: true, + }, + { + id: 2, + title: '점심 메뉴 추천 부탁드립니다', + content: '오늘 점심 뭐 먹을까요? 추천 부탁드립니다!', + writer: { id: 2, nickname: '김대해' }, + createdAt: '2024-07-24T14:30:00Z', + likeCount: 856, + isBest: true, + }, + { + id: 3, + title: '회의실 예약 시스템 개선 제안', + content: '회의실 예약 시스템이 불편해서 개선 제안드립니다.', + writer: { id: 3, nickname: '이연지' }, + createdAt: '2024-07-23T10:15:00Z', + likeCount: 789, + image: 'https://picsum.photos/400/401', + isBest: true, + }, + ]; + + return ( +
+ {articles.map((article) => ( + + ))} +
+ ); + }, + parameters: { + docs: { + description: { + story: '여러 개의 베스트 게시글을 나열한 예시입니다 (베스트 게시글: 350x210).', + }, + }, + }, +}; diff --git a/src/components/Card/ArticleCard/ArticleCard.tsx b/src/components/Card/ArticleCard/ArticleCard.tsx new file mode 100644 index 0000000..78a3209 --- /dev/null +++ b/src/components/Card/ArticleCard/ArticleCard.tsx @@ -0,0 +1,108 @@ +import Image from 'next/image'; +import styles from './ArticleCard.module.css'; +import bestIcon from '@/assets/icons/best/best.svg'; +import emptyHeartIcon from '@/assets/icons/heart/emptyHeartLarge.svg'; + +interface ArticleCardProps { + id: number; + title: string; + /** 게시글 본문 (API 추가 시 사용) */ + content?: string; + writer: { + nickname: string; + id: number; + }; + createdAt: string; + likeCount: number; + image?: string; + isBest?: boolean; + onClick?: () => void; +} + +/** + * ArticleCard + * + * 자유게시판 게시글을 카드 형태로 표시하는 컴포넌트입니다. + * + * @remarks + * - 목록 조회 API(ArticleListType)의 데이터 구조와 일치합니다. + * - `isBest` prop은 페이지에서 계산하여 전달합니다 (좋아요 상위 N개). + * - `content` 필드는 현재 API에 없지만, 추후 추가를 대비해 optional로 정의했습니다. + * - 이미지가 있으면 우측에 표시되고, 없으면 텍스트가 전체 공간을 차지합니다. + * + * @example + * ```tsx + * router.push(`/articles/${article.id}`)} + * /> + * ``` + */ +export default function ArticleCard({ + title, + content, + writer, + createdAt, + likeCount, + image, + isBest = false, + onClick, +}: ArticleCardProps) { + const formatDate = (dateString: string): string => { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}. ${month}. ${day}`; + }; + + const formatLikeCount = (count: number): string => { + if (count >= 1000) { + return '999+'; + } + return count.toString(); + }; + + return ( +
+
+ {isBest && ( +
+ + 인기 +
+ )} + +

{title}

+ + {content && ( +

+ {content.length > 100 ? `${content.slice(0, 100)}...` : content} +

+ )} + +
+ {writer.nickname} + | + +
+
+ +
+ {image && ( +
+ +
+ )} + +
+ + {formatLikeCount(likeCount)} +
+
+
+ ); +} diff --git a/src/components/Card/TaskCard.module.css b/src/components/Card/TaskCard/TaskCard.module.css similarity index 100% rename from src/components/Card/TaskCard.module.css rename to src/components/Card/TaskCard/TaskCard.module.css diff --git a/src/components/Card/TaskCard.stories.tsx b/src/components/Card/TaskCard/TaskCard.stories.tsx similarity index 100% rename from src/components/Card/TaskCard.stories.tsx rename to src/components/Card/TaskCard/TaskCard.stories.tsx diff --git a/src/components/Card/TaskCard.tsx b/src/components/Card/TaskCard/TaskCard.tsx similarity index 100% rename from src/components/Card/TaskCard.tsx rename to src/components/Card/TaskCard/TaskCard.tsx diff --git a/src/components/Card/TaskDetailCard.module.css b/src/components/Card/TaskDetailCard/TaskDetailCard.module.css similarity index 100% rename from src/components/Card/TaskDetailCard.module.css rename to src/components/Card/TaskDetailCard/TaskDetailCard.module.css diff --git a/src/components/Card/TaskDetailCard.stories.tsx b/src/components/Card/TaskDetailCard/TaskDetailCard.stories.tsx similarity index 100% rename from src/components/Card/TaskDetailCard.stories.tsx rename to src/components/Card/TaskDetailCard/TaskDetailCard.stories.tsx diff --git a/src/components/Card/TaskDetailCard.tsx b/src/components/Card/TaskDetailCard/TaskDetailCard.tsx similarity index 100% rename from src/components/Card/TaskDetailCard.tsx rename to src/components/Card/TaskDetailCard/TaskDetailCard.tsx From 5b2af01febdefa1e8a7b2c7192b6e1aa5937d395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A5=AD=5Cleevi?= Date: Tue, 10 Feb 2026 12:39:40 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=B9=B4=EB=93=9C=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Card/ImageUpload/ImageUpload.module.css | 99 ++++++++ .../Card/ImageUpload/ImageUpload.stories.tsx | 217 ++++++++++++++++++ .../Card/ImageUpload/ImageUpload.tsx | 131 +++++++++++ 3 files changed, 447 insertions(+) create mode 100644 src/components/Card/ImageUpload/ImageUpload.module.css create mode 100644 src/components/Card/ImageUpload/ImageUpload.stories.tsx create mode 100644 src/components/Card/ImageUpload/ImageUpload.tsx diff --git a/src/components/Card/ImageUpload/ImageUpload.module.css b/src/components/Card/ImageUpload/ImageUpload.module.css new file mode 100644 index 0000000..74a622c --- /dev/null +++ b/src/components/Card/ImageUpload/ImageUpload.module.css @@ -0,0 +1,99 @@ +.container { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.slot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-primary); + border-radius: 12px; + background: var(--color-background-primary); + overflow: hidden; +} + +.large { + width: 120px; + height: 120px; +} + +.small { + width: 80px; + height: 80px; +} + +.uploadSlot { + cursor: pointer; + transition: background-color 0.2s; +} + +.uploadSlot:hover { + background: var(--color-background-secondary); +} + +.uploadSlot:active { + transform: scale(0.98); +} + +.emptySlot { + opacity: 0.3; + cursor: not-allowed; +} + +.count { + margin-top: 8px; + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.removeButton { + position: absolute; + top: 4px; + right: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: rgba(0, 0, 0, 0.6); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.2s; +} + +.removeButton:hover { + background: rgba(0, 0, 0, 0.8); +} + +.hiddenInput { + display: none; +} + +@media (max-width: 767px) { + .large { + width: 100px; + height: 100px; + } + + .small { + width: 70px; + height: 70px; + } + + .count { + font-size: 12px; + } +} diff --git a/src/components/Card/ImageUpload/ImageUpload.stories.tsx b/src/components/Card/ImageUpload/ImageUpload.stories.tsx new file mode 100644 index 0000000..6325436 --- /dev/null +++ b/src/components/Card/ImageUpload/ImageUpload.stories.tsx @@ -0,0 +1,217 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; +import ImageUpload from './ImageUpload'; + +const meta = { + title: 'Components/Card/ImageUpload', + component: ImageUpload, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +ImageUpload는 이미지 업로드 UI 컴포넌트입니다. + +- 최대 5개까지 이미지를 업로드할 수 있습니다. +- 업로드할 때마다 슬롯이 동적으로 추가됩니다. +- 각 슬롯에 "N/5" 형태로 현재 개수를 표시합니다. +- 업로드된 이미지는 미리보기와 함께 삭제 버튼이 표시됩니다. +- 파일 크기는 10MB로 제한되며, 이미지 파일만 업로드 가능합니다. + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + images: { + control: 'object', + description: '업로드된 이미지 URL 배열', + }, + maxImages: { + control: 'number', + description: '최대 업로드 개수 (default: 5)', + }, + onFileSelect: { + action: 'file-selected', + description: '파일 선택 시 호출', + }, + onRemove: { + action: 'removed', + description: '이미지 삭제', + }, + size: { + control: 'select', + options: ['large', 'small'], + description: '크기', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// 기본 (이미지 없음) +export const Default: Story = { + args: { + images: [], + maxImages: 5, + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: '기본 상태입니다. 0/5 슬롯만 표시됩니다.', + }, + }, + }, +}; + +// 이미지 1개 +export const OneImage: Story = { + args: { + images: ['https://picsum.photos/400/400'], + maxImages: 5, + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: '이미지 1개 업로드된 상태입니다. 이미지 슬롯 + 1/5 슬롯이 표시됩니다.', + }, + }, + }, +}; + +// 이미지 3개 +export const ThreeImages: Story = { + args: { + images: [ + 'https://picsum.photos/400/400', + 'https://picsum.photos/400/401', + 'https://picsum.photos/400/402', + ], + maxImages: 5, + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: '이미지 3개 업로드된 상태입니다. 이미지 3개 + 3/5 슬롯이 표시됩니다.', + }, + }, + }, +}; + +// 최대 (5개) +export const Full: Story = { + args: { + images: [ + 'https://picsum.photos/400/400', + 'https://picsum.photos/400/401', + 'https://picsum.photos/400/402', + 'https://picsum.photos/400/403', + 'https://picsum.photos/400/404', + ], + maxImages: 5, + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: '최대 개수(5개) 업로드된 상태입니다. 업로드 버튼이 사라집니다.', + }, + }, + }, +}; + +// Small 크기 +export const SmallSize: Story = { + args: { + images: ['https://picsum.photos/400/400', 'https://picsum.photos/400/401'], + maxImages: 5, + size: 'small', + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: 'Small 크기입니다 (80x80).', + }, + }, + }, +}; + +// 최대 개수 3개로 제한 +export const MaxThree: Story = { + args: { + images: ['https://picsum.photos/400/400'], + maxImages: 3, + onFileSelect: fn(), + onRemove: fn(), + }, + parameters: { + docs: { + description: { + story: '최대 개수를 3개로 제한한 예시입니다.', + }, + }, + }, +}; + +// 인터랙티브 예제 +export const Interactive: Story = { + args: { + images: [], + maxImages: 5, + onFileSelect: fn(), + onRemove: fn(), + }, + render: (args) => { + const InteractiveExample = () => { + const [images, setImages] = useState([]); + + const handleFileSelect = (file: File) => { + // 실제로는 API 호출 + // const url = await uploadImage(file); + + // Mock: URL.createObjectURL 사용 + const url = URL.createObjectURL(file); + setImages([...images, url]); + }; + + const handleRemove = (index: number) => { + setImages(images.filter((_, i) => i !== index)); + }; + + return ( +
+ +

+ 업로드된 이미지: {images.length}/{args.maxImages} +

+
+ ); + }; + + return ; + }, + parameters: { + docs: { + description: { + story: '실제 파일 선택이 가능한 인터랙티브 예제입니다. 동적으로 슬롯이 추가됩니다.', + }, + }, + }, +}; diff --git a/src/components/Card/ImageUpload/ImageUpload.tsx b/src/components/Card/ImageUpload/ImageUpload.tsx new file mode 100644 index 0000000..802ff98 --- /dev/null +++ b/src/components/Card/ImageUpload/ImageUpload.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useRef } from 'react'; +import Image from 'next/image'; +import styles from './ImageUpload.module.css'; +import imgIcon from '@/assets/icons/img/img.svg'; +import xMarkIcon from '@/assets/icons/xMark/xMarkBig.svg'; + +interface ImageUploadProps { + /** 업로드된 이미지 URL 배열 */ + images: string[]; + /** 최대 업로드 개수 */ + maxImages?: number; + /** 파일 선택 시 호출 (실제 업로드는 부모에서 처리) */ + onFileSelect: (file: File) => void; + /** 이미지 삭제 */ + onRemove: (index: number) => void; + /** 크기 */ + size?: 'large' | 'small'; +} + +/** + * ImageUpload + * + * 이미지 업로드 UI 컴포넌트입니다. + * + * @remarks + * - 최대 5개까지 이미지를 업로드할 수 있습니다. + * - 업로드할 때마다 슬롯이 동적으로 추가됩니다. + * - 각 업로드 슬롯에 "N/5" 형태로 현재 개수를 표시합니다. + * - 실제 API 호출은 부모 컴포넌트에서 처리합니다 (1개씩 업로드). + * - 업로드된 이미지는 미리보기와 함께 삭제 버튼이 표시됩니다. + * + * @example + * ```tsx + * const [images, setImages] = useState([]); + * + * const handleFileSelect = async (file: File) => { + * const url = await uploadImage(file); // API 호출 + * setImages([...images, url]); + * }; + * + * setImages(images.filter((_, i) => i !== index))} + * /> + * ``` + */ +export default function ImageUpload({ + images, + maxImages = 5, + onFileSelect, + onRemove, + size = 'large', +}: ImageUploadProps) { + const inputRef = useRef(null); + + const handleClick = () => { + if (images.length >= maxImages) return; + inputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // 10MB 제한 + if (file.size > 10 * 1024 * 1024) { + alert('파일 크기는 10MB를 초과할 수 없습니다.'); + return; + } + + // 이미지 파일만 + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드할 수 있습니다.'); + return; + } + + onFileSelect(file); + + // input 초기화 (같은 파일 재선택 가능) + if (inputRef.current) { + inputRef.current.value = ''; + } + }; + + // 동적 슬롯: 업로드된 이미지 + 업로드 버튼 (최대 개수 전까지) + const showUploadButton = images.length < maxImages; + + return ( +
+ {/* 업로드된 이미지들 */} + {images.map((imageUrl, index) => ( +
+ + +
+ ))} + + {/* 업로드 버튼 (최대 개수 전까지만 표시) */} + {showUploadButton && ( + + )} + + +
+ ); +}