Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/app/providers/Providers.tsx
Original file line number Diff line number Diff line change
@@ -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 <SidebarProvider>{children}</SidebarProvider>
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<SidebarProvider>{children}</SidebarProvider>
<Toaster duration={1500} className="text-center" />
</ThemeProvider>
)
}
7 changes: 7 additions & 0 deletions src/features/create-post/constants.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
26 changes: 26 additions & 0 deletions src/features/create-post/model/hooks/useCaption.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
55 changes: 55 additions & 0 deletions src/features/create-post/model/hooks/useCreatePostDraft.ts
Original file line number Diff line number Diff line change
@@ -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<File[]>([])
const [step, setStep] = useState<CreatePostStep>('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,
}
}
38 changes: 38 additions & 0 deletions src/features/create-post/model/hooks/useDiscardConfirm.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
70 changes: 70 additions & 0 deletions src/features/create-post/model/hooks/useImageCarousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useCallback, useMemo, useState } from 'react'

type UseImageCarouselResult = {
activeIndex: number
setActiveIndex: React.Dispatch<React.SetStateAction<number>>
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<React.SetStateAction<number>>
>(
(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,
}
}
24 changes: 24 additions & 0 deletions src/features/create-post/model/hooks/useLimitedFilesDrop.ts
Original file line number Diff line number Diff line change
@@ -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]
)
}
Loading