diff --git a/package.json b/package.json index df59d3a..6863ed2 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,10 @@ "immer": "^11.1.3", "ky": "^1.14.2", "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "vaul": "^1.1.2", @@ -51,7 +53,10 @@ "@tanstack/eslint-plugin-query": "^5.91.2", "@tanstack/react-router-devtools": "^1.144.0", "@tanstack/router-plugin": "^1.145.4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/src/app/providers/Providers.tsx b/src/app/providers/Providers.tsx index d14da2f..45aa63b 100644 --- a/src/app/providers/Providers.tsx +++ b/src/app/providers/Providers.tsx @@ -1,7 +1,15 @@ import type { PropsWithChildren } from 'react' +import { ThemeProvider } from 'next-themes' + import { SidebarProvider } from '@/shared/ui/sidebar' +import { Toaster } from '@/shared/ui/sonner' export function Providers({ children }: PropsWithChildren) { - return {children} + return ( + + {children} + + + ) } diff --git a/src/features/create-post/constants.ts b/src/features/create-post/constants.ts new file mode 100644 index 0000000..4da58c0 --- /dev/null +++ b/src/features/create-post/constants.ts @@ -0,0 +1,7 @@ +export const MAX_IMAGE_FILES = 10 + +export const MAX_IMAGE_FILE_SIZE_BYTES = 10 * 1024 * 1024 + +export const CREATE_POST_IMAGE_ACCEPT = 'image/*' + +export const MAX_CAPTION_LENGTH = 2200 diff --git a/src/features/create-post/model/hooks/__tests__/useCaption.test.ts b/src/features/create-post/model/hooks/__tests__/useCaption.test.ts new file mode 100644 index 0000000..6fc7ca6 --- /dev/null +++ b/src/features/create-post/model/hooks/__tests__/useCaption.test.ts @@ -0,0 +1,18 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect } from 'vitest' + +import { useCaption } from '../useCaption' + +describe('useCaption', () => { + it('maxLength를 넘는 입력은 잘라서 저장한다', () => { + const { result } = renderHook(() => useCaption({ maxLength: 5 })) + + act(() => { + result.current.setCaption('abcdef') + }) + + expect(result.current.caption).toBe('abcde') + expect(result.current.captionLength).toBe(5) + expect(result.current.maxLength).toBe(5) + }) +}) diff --git a/src/features/create-post/model/hooks/__tests__/useCreatePostDraft.test.ts b/src/features/create-post/model/hooks/__tests__/useCreatePostDraft.test.ts new file mode 100644 index 0000000..a58047b --- /dev/null +++ b/src/features/create-post/model/hooks/__tests__/useCreatePostDraft.test.ts @@ -0,0 +1,63 @@ +import { renderHook, act } from '@testing-library/react' +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest' + +import { useCreatePostDraft } from '../useCreatePostDraft' + +describe('useCreatePostDraft', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('파일 선택 시 maxFiles로 제한하고 초과분(ignoredCount)을 알려준다', () => { + const onIgnoredCount = vi.fn() + + const { result } = renderHook(() => + useCreatePostDraft({ maxFiles: 2, onIgnoredCount }) + ) + + const file1 = new File(['a'], 'a.png', { type: 'image/png' }) + const file2 = new File(['b'], 'b.png', { type: 'image/png' }) + const file3 = new File(['c'], 'c.png', { type: 'image/png' }) + + act(() => { + result.current.handleDropFiles([file1, file2, file3]) + }) + + expect(result.current.files).toHaveLength(2) + expect(onIgnoredCount).toHaveBeenCalledWith(1) + expect(result.current.step).toBe('select') + expect(result.current.isUploaded).toBe(true) + expect(result.current.activePreviewUrl).toBe('blob:mock') + }) + + it('draft 초기화시 files/step/carousel index를 모두 리셋한다', () => { + const { result } = renderHook(() => useCreatePostDraft({ maxFiles: 10 })) + + const file1 = new File(['a'], 'a.png', { type: 'image/png' }) + const file2 = new File(['b'], 'b.png', { type: 'image/png' }) + + act(() => { + result.current.handleDropFiles([file1, file2]) + }) + + act(() => { + result.current.carousel.goNext() + }) + + expect(result.current.carousel.activeIndex).toBe(1) + + act(() => { + result.current.resetDraft() + }) + + expect(result.current.files).toHaveLength(0) + expect(result.current.step).toBe('select') + expect(result.current.isUploaded).toBe(false) + expect(result.current.carousel.activeIndex).toBe(0) + }) +}) diff --git a/src/features/create-post/model/hooks/__tests__/useDiscardConfirm.test.ts b/src/features/create-post/model/hooks/__tests__/useDiscardConfirm.test.ts new file mode 100644 index 0000000..e3912f2 --- /dev/null +++ b/src/features/create-post/model/hooks/__tests__/useDiscardConfirm.test.ts @@ -0,0 +1,48 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +import { useDiscardConfirm } from '../useDiscardConfirm' + +describe('useDiscardConfirm', () => { + it('작성 중 변경사항이 없으면 닫기 요청 시 confirm 없이 바로 닫는다', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useDiscardConfirm({ isDirty: false, onClose }) + ) + + act(() => { + result.current.requestClose() + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(result.current.isConfirmOpen).toBe(false) + }) + + it('작성 중 변경사항이 있으면 닫기 요청 시 confirm을 열고 즉시 닫지는 않는다', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useDiscardConfirm({ isDirty: true, onClose }) + ) + + act(() => { + result.current.requestClose() + }) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.isConfirmOpen).toBe(true) + }) + + it('Dialog의 onOpenChange(false)에서도 동일하게 닫기 정책(confirm/close)이 적용된다', () => { + const onClose = vi.fn() + const { result } = renderHook(() => + useDiscardConfirm({ isDirty: true, onClose }) + ) + + act(() => { + result.current.handleDialogOpenChange(false) + }) + + expect(onClose).not.toHaveBeenCalled() + expect(result.current.isConfirmOpen).toBe(true) + }) +}) diff --git a/src/features/create-post/model/hooks/useCaption.ts b/src/features/create-post/model/hooks/useCaption.ts new file mode 100644 index 0000000..196764a --- /dev/null +++ b/src/features/create-post/model/hooks/useCaption.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react' + +type UseCaptionParams = { + maxLength: number + initialValue?: string +} + +export function useCaption({ maxLength, initialValue = '' }: UseCaptionParams) { + const [caption, setCaption] = useState(initialValue) + + const captionLength = caption.length + + const setNextCaption = useCallback( + (next: string) => { + setCaption(next.slice(0, maxLength)) + }, + [maxLength] + ) + + return { + caption, + setCaption: setNextCaption, + captionLength, + maxLength, + } +} diff --git a/src/features/create-post/model/hooks/useCreatePostDraft.ts b/src/features/create-post/model/hooks/useCreatePostDraft.ts new file mode 100644 index 0000000..3d470e0 --- /dev/null +++ b/src/features/create-post/model/hooks/useCreatePostDraft.ts @@ -0,0 +1,55 @@ +import { useCallback, useMemo, useState } from 'react' + +import { useImageCarousel } from '@/features/create-post/model/hooks/useImageCarousel' +import { useLimitedFilesDrop } from '@/features/create-post/model/hooks/useLimitedFilesDrop' +import { useObjectUrl } from '@/features/create-post/model/hooks/useObjectUrl' + +export type CreatePostStep = 'select' | 'details' + +type UseCreatePostDraftParams = { + maxFiles: number + onIgnoredCount?: (ignoredCount: number) => void +} + +export function useCreatePostDraft({ + maxFiles, + onIgnoredCount, +}: UseCreatePostDraftParams) { + const [files, setFiles] = useState([]) + const [step, setStep] = useState('select') + + const carousel = useImageCarousel(files.length) + const isUploaded = useMemo(() => files.length > 0, [files.length]) + const isDetails = isUploaded && step === 'details' + + const resetDraft = useCallback(() => { + setFiles([]) + setStep('select') + carousel.reset() + }, [carousel]) + + const handleDropFiles = useLimitedFilesDrop({ + maxFiles, + onAcceptedFiles: (limited) => { + setFiles(limited) + setStep('select') + carousel.reset() + }, + onIgnoredCount, + }) + + const activeFile = files[carousel.activeIndex] + const activePreviewUrl = useObjectUrl(activeFile) + + return { + files, + step, + setStep, + isUploaded, + isDetails, + activePreviewUrl, + carousel, + handleDropFiles, + resetDraft, + } +} diff --git a/src/features/create-post/model/hooks/useDiscardConfirm.ts b/src/features/create-post/model/hooks/useDiscardConfirm.ts new file mode 100644 index 0000000..f10abd5 --- /dev/null +++ b/src/features/create-post/model/hooks/useDiscardConfirm.ts @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react' + +type UseDiscardConfirmParams = { + isDirty: boolean + onClose: () => void +} + +export function useDiscardConfirm({ + isDirty, + onClose, +}: UseDiscardConfirmParams) { + const [isConfirmOpen, setConfirmOpen] = useState(false) + + const requestClose = useCallback(() => { + if (isDirty) { + setConfirmOpen(true) + return + } + onClose() + }, [isDirty, onClose]) + + const handleDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) requestClose() + }, + [requestClose] + ) + + const closeConfirm = useCallback(() => setConfirmOpen(false), []) + + return { + isConfirmOpen, + setConfirmOpen, + closeConfirm, + requestClose, + handleDialogOpenChange, + } +} diff --git a/src/features/create-post/model/hooks/useImageCarousel.ts b/src/features/create-post/model/hooks/useImageCarousel.ts new file mode 100644 index 0000000..c4a2d59 --- /dev/null +++ b/src/features/create-post/model/hooks/useImageCarousel.ts @@ -0,0 +1,70 @@ +import { useCallback, useMemo, useState } from 'react' + +type UseImageCarouselResult = { + activeIndex: number + setActiveIndex: React.Dispatch> + reset: () => void + canGoPrev: boolean + canGoNext: boolean + goPrev: () => void + goNext: () => void + dots: number[] +} + +export function useImageCarousel(total: number): UseImageCarouselResult { + const [rawActiveIndex, setRawActiveIndex] = useState(0) + + const clampIndex = useCallback( + (index: number) => { + if (total <= 0) return 0 + return Math.min(Math.max(0, index), total - 1) + }, + [total] + ) + + const activeIndex = useMemo( + () => clampIndex(rawActiveIndex), + [rawActiveIndex, clampIndex] + ) + + const setActiveIndex = useCallback< + React.Dispatch> + >( + (next) => { + setRawActiveIndex((prev) => { + const resolved = typeof next === 'function' ? next(prev) : next + return clampIndex(resolved) + }) + }, + [clampIndex] + ) + + const reset = useCallback(() => setActiveIndex(0), [setActiveIndex]) + + const canGoPrev = activeIndex > 0 + const canGoNext = total > 0 && activeIndex < total - 1 + + const goPrev = useCallback(() => { + setActiveIndex((i) => i - 1) + }, [setActiveIndex]) + + const goNext = useCallback(() => { + setActiveIndex((i) => i + 1) + }, [setActiveIndex]) + + const dots = useMemo( + () => Array.from({ length: total }, (_, i) => i), + [total] + ) + + return { + activeIndex, + setActiveIndex, + reset, + canGoPrev, + canGoNext, + goPrev, + goNext, + dots, + } +} diff --git a/src/features/create-post/model/hooks/useLimitedFilesDrop.ts b/src/features/create-post/model/hooks/useLimitedFilesDrop.ts new file mode 100644 index 0000000..b277293 --- /dev/null +++ b/src/features/create-post/model/hooks/useLimitedFilesDrop.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react' + +type UseLimitedFilesDropParams = { + maxFiles: number + onAcceptedFiles: (files: File[]) => void + onIgnoredCount?: (ignoredCount: number) => void +} + +export function useLimitedFilesDrop({ + maxFiles, + onAcceptedFiles, + onIgnoredCount, +}: UseLimitedFilesDropParams) { + return useCallback( + (incomingFiles: File[]) => { + const limited = incomingFiles.slice(0, maxFiles) + const ignoredCount = Math.max(0, incomingFiles.length - limited.length) + + onAcceptedFiles(limited) + onIgnoredCount?.(ignoredCount) + }, + [maxFiles, onAcceptedFiles, onIgnoredCount] + ) +} diff --git a/src/features/create-post/model/hooks/useObjectUrl.ts b/src/features/create-post/model/hooks/useObjectUrl.ts new file mode 100644 index 0000000..1e14714 --- /dev/null +++ b/src/features/create-post/model/hooks/useObjectUrl.ts @@ -0,0 +1,13 @@ +import { useEffect, useMemo } from 'react' + +export function useObjectUrl(file: File | null | undefined) { + const url = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]) + + useEffect(() => { + return () => { + if (url) URL.revokeObjectURL(url) + } + }, [url]) + + return url +} diff --git a/src/features/create-post/ui/CreateModal.tsx b/src/features/create-post/ui/CreateModal.tsx index 50e9c15..7dcda20 100644 --- a/src/features/create-post/ui/CreateModal.tsx +++ b/src/features/create-post/ui/CreateModal.tsx @@ -1,4 +1,21 @@ +import { useCallback } from 'react' + import { Dialog, DialogContent } from '@/shared/ui/dialog' +import { Dropzone } from '@/shared/ui/dropzone' +import { toast } from 'sonner' + +import { + CREATE_POST_IMAGE_ACCEPT, + MAX_IMAGE_FILES, + MAX_IMAGE_FILE_SIZE_BYTES, +} from '@/features/create-post/constants' +import { useCreatePostDraft } from '@/features/create-post/model/hooks/useCreatePostDraft' +import { useDiscardConfirm } from '@/features/create-post/model/hooks/useDiscardConfirm' +import { CreateModalHeader } from '@/features/create-post/ui/CreateModalHeader' +import { DiscardCreatePostDialog } from '@/features/create-post/ui/DiscardCreatePostDialog' +import { EmptyDropzoneState } from '@/features/create-post/ui/EmptyDropzoneState' +import { PreviewPane } from '@/features/create-post/ui/PreviewPane' +import { PostDetailsPane } from '@/features/create-post/ui/PostDetailsPane' type CreateModalProps = { open: boolean @@ -6,11 +23,119 @@ type CreateModalProps = { } export function CreateModal({ open, onOpenChange }: CreateModalProps) { + if (!open) return null + return +} + +function CreateModalInner({ + onOpenChange, +}: Pick) { + const { + files, + step, + setStep, + isUploaded, + isDetails, + activePreviewUrl, + carousel, + handleDropFiles, + resetDraft, + } = useCreatePostDraft({ + maxFiles: MAX_IMAGE_FILES, + onIgnoredCount: (ignoredCount) => { + if (ignoredCount <= 0) return + toast( + `사진 ${ignoredCount}장이 업로드 되지 않았습니다.\n최대 ${MAX_IMAGE_FILES}개의 파일만 선택할 수 있습니다.` + ) + }, + }) + + const closeWithoutConfirm = useCallback(() => { + resetDraft() + onOpenChange(false) + }, [onOpenChange, resetDraft]) + + const { + isConfirmOpen, + setConfirmOpen, + requestClose, + handleDialogOpenChange, + } = useDiscardConfirm({ + isDirty: isUploaded, + onClose: closeWithoutConfirm, + }) + return ( - - - - - + <> + + + setStep('select') : requestClose} + onNext={() => setStep('details')} + onShare={() => toast('공유하기')} + /> + + + {isUploaded ? ( + isDetails ? ( + + + + + + + + + + ) : ( + + ) + ) : ( + + {(api) => } + + )} + + + + + > ) } diff --git a/src/features/create-post/ui/CreateModalHeader.tsx b/src/features/create-post/ui/CreateModalHeader.tsx new file mode 100644 index 0000000..0f4c1e5 --- /dev/null +++ b/src/features/create-post/ui/CreateModalHeader.tsx @@ -0,0 +1,63 @@ +import { Button } from '@/shared/ui/button' +import { DialogClose, DialogTitle } from '@/shared/ui/dialog' +import { ChevronLeft, XIcon } from 'lucide-react' + +type CreateModalHeaderProps = { + isUploaded: boolean + step?: 'select' | 'details' + onBack?: () => void + onNext?: () => void + onShare?: () => void +} + +export function CreateModalHeader({ + isUploaded, + step = 'select', + onBack, + onNext, + onShare, +}: CreateModalHeaderProps) { + const isDetails = isUploaded && step === 'details' + + return ( + + {isUploaded ? ( + + + + ) : null} + + + 새 게시물 만들기 + + + {isUploaded ? ( + + {isDetails ? '공유하기' : '다음으로'} + + ) : ( + + + + + + )} + + ) +} diff --git a/src/features/create-post/ui/DiscardCreatePostDialog.tsx b/src/features/create-post/ui/DiscardCreatePostDialog.tsx new file mode 100644 index 0000000..ea729cf --- /dev/null +++ b/src/features/create-post/ui/DiscardCreatePostDialog.tsx @@ -0,0 +1,57 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/shared/ui/dialog' + +type DiscardCreatePostDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + onDiscard: () => void +} + +export function DiscardCreatePostDialog({ + open, + onOpenChange, + onDiscard, +}: DiscardCreatePostDialogProps) { + return ( + + + + + 게시물을 삭제하시겠어요? + + + 지금 나가면 수정 내용이 저장되지 않습니다. + + + + + + + 삭제 + + + + + onOpenChange(false)} + > + 취소 + + + + ) +} diff --git a/src/features/create-post/ui/EmptyDropzoneState.tsx b/src/features/create-post/ui/EmptyDropzoneState.tsx new file mode 100644 index 0000000..f5e5afe --- /dev/null +++ b/src/features/create-post/ui/EmptyDropzoneState.tsx @@ -0,0 +1,53 @@ +import type { ComponentPropsWithRef, HTMLAttributes } from 'react' + +import { Button } from '@/shared/ui/button' +import { ImagePlus } from 'lucide-react' + +type EmptyDropzoneStateProps = { + getRootProps: ( + props?: HTMLAttributes + ) => HTMLAttributes + getInputProps: ( + props?: ComponentPropsWithRef<'input'> + ) => ComponentPropsWithRef<'input'> + openFileDialog: () => void + isDragging: boolean +} + +export function EmptyDropzoneState({ + getRootProps, + getInputProps, + openFileDialog, + isDragging, +}: EmptyDropzoneStateProps) { + return ( + + + + + 사진과 동영상을 여기에 끌어다 놓으세요 + + + + + { + e.preventDefault() + e.stopPropagation() + openFileDialog() + }} + > + 컴퓨터에서 선택 + + + ) +} diff --git a/src/features/create-post/ui/PostDetailsPane.tsx b/src/features/create-post/ui/PostDetailsPane.tsx new file mode 100644 index 0000000..1333189 --- /dev/null +++ b/src/features/create-post/ui/PostDetailsPane.tsx @@ -0,0 +1,48 @@ +import { MAX_CAPTION_LENGTH } from '@/features/create-post/constants' +import { useCaption } from '@/features/create-post/model/hooks/useCaption' + +type PostDetailsPaneProps = { + profileName: string + profileImageUrl?: string +} + +export function PostDetailsPane({ + profileName, + profileImageUrl, +}: PostDetailsPaneProps) { + const { caption, setCaption, captionLength, maxLength } = useCaption({ + maxLength: MAX_CAPTION_LENGTH, + }) + + return ( + + ) +} diff --git a/src/features/create-post/ui/PreviewPane.tsx b/src/features/create-post/ui/PreviewPane.tsx new file mode 100644 index 0000000..0f18e25 --- /dev/null +++ b/src/features/create-post/ui/PreviewPane.tsx @@ -0,0 +1,93 @@ +import { Button } from '@/shared/ui/button' +import { ChevronLeft, ChevronRight } from 'lucide-react' + +type PreviewPaneProps = { + activePreviewUrl: string | null | undefined + filesCount: number + activeIndex: number + canGoPrev: boolean + canGoNext: boolean + dots: number[] + goPrev: () => void + goNext: () => void +} + +export function PreviewPane({ + activePreviewUrl, + filesCount, + activeIndex, + canGoPrev, + canGoNext, + dots, + goPrev, + goNext, +}: PreviewPaneProps) { + const hasMultipleFiles = filesCount > 1 + + return ( + + { + e.preventDefault() + e.stopPropagation() + }} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + }} + > + {activePreviewUrl ? ( + + ) : null} + + {hasMultipleFiles ? ( + <> + + + + + + + + + + {dots.map((i) => { + const isActive = i === activeIndex + return ( + + ) + })} + + > + ) : null} + + + ) +} diff --git a/src/features/create-post/ui/__tests__/CreateModal.test.tsx b/src/features/create-post/ui/__tests__/CreateModal.test.tsx new file mode 100644 index 0000000..49eac15 --- /dev/null +++ b/src/features/create-post/ui/__tests__/CreateModal.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest' + +import { CreateModal } from '../CreateModal' + +function getFileInput() { + const input = document.querySelector('input[type="file"]') + if (!input) throw new Error('file input not found') + return input as HTMLInputElement +} + +function getFirstDialogOverlay() { + const overlay = document.querySelector('[data-slot="dialog-overlay"]') + if (!overlay) throw new Error('dialog overlay not found') + return overlay as HTMLElement +} + +function clickOutsideToRequestClose() { + getFirstDialogOverlay() + fireEvent.pointerDown(document.body) + fireEvent.pointerUp(document.body) +} + +describe('CreateModal', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('업로드 전에는 닫힘 요청 시 confirm 없이 바로 닫힌다', async () => { + const onOpenChange = vi.fn() + render() + expect(screen.queryByText('게시물을 삭제하시겠어요?')).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + expect(screen.queryByText('게시물을 삭제하시겠어요?')).toBeNull() + }) + + it('업로드 후에는 바깥을 누르면 confirm이 뜨고, 삭제를 누르면 닫힌다', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + render() + + const file = new File(['hello'], 'a.png', { type: 'image/png' }) + await user.upload(getFileInput(), file) + + clickOutsideToRequestClose() + + expect(onOpenChange).not.toHaveBeenCalled() + expect( + await screen.findByText('게시물을 삭제하시겠어요?') + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '취소' })) + await waitFor(() => { + expect(screen.queryByText('게시물을 삭제하시겠어요?')).toBeNull() + }) + + clickOutsideToRequestClose() + await user.click(screen.getByRole('button', { name: '삭제' })) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) +}) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5e336fc..e092dc6 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,3 +1,9 @@ +import { NavigationShell } from '@/widgets/navigation-sidebar/ui/NavigationShell' + export const HomePage = () => { - return HomePage + return ( + + + + ) } diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx index 4c2be56..a7133e4 100644 --- a/src/shared/ui/dialog.tsx +++ b/src/shared/ui/dialog.tsx @@ -48,13 +48,15 @@ function DialogContent({ className, children, showCloseButton = true, + overlayClassName, ...props }: React.ComponentProps & { showCloseButton?: boolean + overlayClassName?: string }) { return ( - + + ) => HTMLAttributes + getInputProps: ( + props?: ComponentPropsWithRef<'input'> + ) => ComponentPropsWithRef<'input'> + openFileDialog: () => void + isDragging: boolean + isDisabled: boolean +} + +type DropzoneProps = { + onDropFiles: (files: File[]) => void + onReject?: (rejected: RejectedFile[]) => void + accept?: string + multiple?: boolean + disabled?: boolean + maxSizeBytes?: number + validateFile?: (file: File) => { ok: true } | { ok: false; reason: string } + children: (api: DropzoneApi) => ReactNode +} + +function composeEventHandlers( + theirs: ((event: E) => void) | undefined, + ours: (event: E) => void +) { + return (event: E) => { + theirs?.(event) + if ((event as unknown as { defaultPrevented?: boolean }).defaultPrevented) + return + ours(event) + } +} + +function isFileAccepted(file: File, accept: string | undefined) { + if (!accept) return true + + const tokens = accept + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + + if (tokens.length === 0) return true + + const fileName = file.name.toLowerCase() + const fileType = file.type + + return tokens.some((token) => { + if (token === '*/*') return true + if (token.startsWith('.')) return fileName.endsWith(token.toLowerCase()) + if (token.endsWith('/*')) { + const prefix = token.slice(0, token.length - 1) + return fileType.startsWith(prefix) + } + return fileType === token + }) +} + +export function Dropzone({ + onDropFiles, + onReject, + accept = 'image/*', + multiple = true, + disabled = false, + maxSizeBytes, + validateFile, + children, +}: DropzoneProps) { + const [inputEl, setInputEl] = useState(null) + const [isDragging, setIsDragging] = useState(false) + + const isDisabled = disabled + + const openFileDialog = useCallback(() => { + if (isDisabled) return + inputEl?.click() + }, [inputEl, isDisabled]) + + const handleFiles = useCallback( + (fileList: FileList | null | undefined) => { + if (isDisabled) return + + const rawFiles = fileList ? Array.from(fileList) : [] + if (rawFiles.length === 0) return + + const accepted: File[] = [] + const rejected: RejectedFile[] = [] + + for (const file of rawFiles) { + if (!isFileAccepted(file, accept)) { + rejected.push({ file, reason: '지원하지 않는 파일 형식입니다.' }) + continue + } + + if (typeof maxSizeBytes === 'number' && file.size > maxSizeBytes) { + rejected.push({ file, reason: '파일 용량이 너무 큽니다.' }) + continue + } + + if (validateFile) { + const result = validateFile(file) + if (!result.ok) { + rejected.push({ file, reason: result.reason }) + continue + } + } + + accepted.push(file) + } + + if (rejected.length > 0) onReject?.(rejected) + if (accepted.length > 0) onDropFiles(accepted) + }, + [accept, isDisabled, maxSizeBytes, onDropFiles, onReject, validateFile] + ) + + const getRootProps: DropzoneApi['getRootProps'] = useCallback( + (props = {}) => { + const onDragEnter = composeEventHandlers(props.onDragEnter, (event) => { + if (isDisabled) return + event.preventDefault() + event.stopPropagation() + setIsDragging(true) + }) + + const onDragOver = composeEventHandlers(props.onDragOver, (event) => { + if (isDisabled) return + event.preventDefault() + event.stopPropagation() + setIsDragging(true) + }) + + const onDragLeave = composeEventHandlers(props.onDragLeave, (event) => { + if (isDisabled) return + event.preventDefault() + event.stopPropagation() + setIsDragging(false) + }) + + const onDrop = composeEventHandlers(props.onDrop, (event) => { + if (isDisabled) return + event.preventDefault() + event.stopPropagation() + setIsDragging(false) + handleFiles(event.dataTransfer?.files) + }) + + const onClick = composeEventHandlers(props.onClick, () => { + openFileDialog() + }) + + const onKeyDown = composeEventHandlers(props.onKeyDown, (event) => { + if (isDisabled) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openFileDialog() + } + }) + + return { + ...props, + role: props.role ?? 'button', + tabIndex: props.tabIndex ?? (isDisabled ? -1 : 0), + 'aria-disabled': isDisabled || undefined, + onDragEnter, + onDragOver, + onDragLeave, + onDrop, + onClick, + onKeyDown, + } + }, + [handleFiles, isDisabled, openFileDialog] + ) + + const getInputProps: DropzoneApi['getInputProps'] = useCallback( + (props = {}) => { + const onChange = composeEventHandlers(props.onChange, (event) => { + handleFiles(event.currentTarget.files) + event.currentTarget.value = '' + }) + + return { + ...props, + ref: (node: HTMLInputElement | null) => { + setInputEl(node) + + const consumerRef = props.ref + if (!consumerRef) return + if (typeof consumerRef === 'function') consumerRef(node) + else + (consumerRef as MutableRefObject).current = + node + }, + type: 'file', + accept, + multiple, + disabled: isDisabled, + onChange, + } + }, + [accept, handleFiles, isDisabled, multiple] + ) + + return children({ + getRootProps, + getInputProps, + openFileDialog, + isDragging, + isDisabled, + }) +} diff --git a/src/shared/ui/sonner.tsx b/src/shared/ui/sonner.tsx new file mode 100644 index 0000000..f418e10 --- /dev/null +++ b/src/shared/ui/sonner.tsx @@ -0,0 +1,50 @@ +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from 'lucide-react' +import { useTheme } from 'next-themes' +import { Toaster as Sonner, type ToasterProps } from 'sonner' +import type { CSSProperties } from 'react' + +import { cn } from '@/shared/lib/utils' + +const CENTERED_TOASTER_STYLE: CSSProperties = { + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)', +} + +const Toaster = ({ className, ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme() + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + '--normal-bg': 'rgb(0 0 0 / 0.5)', + '--normal-text': 'white', + '--normal-border': 'transparent', + '--border-radius': '12px', + ...CENTERED_TOASTER_STYLE, + } as CSSProperties + } + /> + ) +} + +export { Toaster } diff --git a/src/test/smoke.test.ts b/src/test/smoke.test.ts index 37e4f31..0c32f4b 100644 --- a/src/test/smoke.test.ts +++ b/src/test/smoke.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' describe('smoke test', () => { - it('vitest works', () => { + it('Vitest가 정상적으로 동작한다', () => { expect(true).toBe(true) }) }) diff --git a/yarn.lock b/yarn.lock index ffe1ed1..b7201a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -39,7 +39,7 @@ resolved "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz" integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q== -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -181,6 +181,11 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/runtime@^7.12.5": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + "@babel/template@^7.27.2": version "7.27.2" resolved "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz" @@ -939,6 +944,20 @@ resolved "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.145.4.tgz" integrity sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ== +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^6.9.1": version "6.9.1" resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz" @@ -951,6 +970,30 @@ picocolors "^1.1.1" redent "^3.0.0" +"@testing-library/react@^16.3.1": + version "16.3.1" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.1.tgz#60a9f1f6a930399d9e41b506a8bf68dbf4831fe8" + integrity sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw== + dependencies: + "@babel/runtime" "^7.12.5" + +"@testing-library/user-event@^14.6.1": + version "14.6.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" + integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== + +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" @@ -1297,6 +1340,11 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.2.1: version "6.2.3" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz" @@ -1327,6 +1375,13 @@ aria-hidden@^1.2.4: dependencies: tslib "^2.0.0" +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + aria-query@^5.0.0: version "5.3.2" resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz" @@ -1778,6 +1833,11 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + detect-file@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz" @@ -1803,6 +1863,11 @@ diff@^8.0.2: resolved "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz" integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg== +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-accessibility-api@^0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz" @@ -2746,6 +2811,11 @@ lucide-react@^0.562.0: resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz" integrity sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@^0.30.21: version "0.30.21" resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" @@ -2871,6 +2941,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-themes@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6" + integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== + node-releases@^2.0.27: version "2.0.27" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" @@ -3012,7 +3087,7 @@ pathe@^2.0.3: resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -3056,6 +3131,15 @@ prettier@^3.0, prettier@^3.5.0, prettier@^3.7.4: resolved "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz" integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" @@ -3068,6 +3152,11 @@ punycode@^2.1.0, punycode@^2.3.1: dependencies: scheduler "^0.27.0" +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-refresh@^0.18.0: version "0.18.0" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz" @@ -3338,6 +3427,10 @@ slice-ansi@^7.1.0: ansi-styles "^6.2.1" is-fullwidth-code-point "^5.0.0" +sonner@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.7.tgz#810c1487a67ec3370126e0f400dfb9edddc3e4f6" + integrity sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w== solid-js@>=1.9.5: version "1.9.10" resolved "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz"
+ 사진과 동영상을 여기에 끌어다 놓으세요 +