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 ? ( + + ) : ( + + + + )} +
+ ) +} 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 ( + + +
+ + 게시물을 삭제하시겠어요? + + + 지금 나가면 수정 내용이 저장되지 않습니다. + +
+ +
+ + + +
+ + + +
+ ) +} 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 ( +
+ + +

+ 사진과 동영상을 여기에 끌어다 놓으세요 +

+ + + + +
+ ) +} 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 ( +