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
31 changes: 31 additions & 0 deletions frontend/src/features/resume/api/resume.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { apiClient } from '@/shared/api'
import type {
PageResponse,
ResumeResponse,
} from '@/features/resume/model/types'

export async function uploadResume(file: File): Promise<ResumeResponse> {
const form = new FormData()
form.append('file', file)
const { data } = await apiClient.post<ResumeResponse>('/api/resumes', form)
return data
}

export async function listResumes(
page = 0,
size = 20,
): Promise<PageResponse<ResumeResponse>> {
const { data } = await apiClient.get<PageResponse<ResumeResponse>>(
'/api/resumes', { params: { page, size, sort: 'createdAt,desc' } },
)
return data
}

export async function getResume(id: number): Promise<ResumeResponse> {
const { data } = await apiClient.get<ResumeResponse>(`/api/resumes/${id}`)
return data
}

export async function deleteResume(id: number): Promise<void> {
await apiClient.delete(`/api/resumes/${id}`)
}
7 changes: 7 additions & 0 deletions frontend/src/features/resume/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { ResumeList } from './ui/ResumeList'
export { UploadResumeButton } from './ui/UploadResumeButton'
export type {
PageResponse,
ResumeResponse,
ResumeStatus,
} from './model/types'
42 changes: 42 additions & 0 deletions frontend/src/features/resume/lib/file-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export const RESUME_MAX_BYTES = 20 * 1024 * 1024
export const RESUME_ACCEPT_MIME = 'application/pdf'
export const RESUME_ACCEPT_EXTENSION = '.pdf'

export type ResumeFileErrorCode =
| 'RESUME_EMPTY_FILE'
| 'RESUME_INVALID_FILE_TYPE'
| 'RESUME_FILE_TOO_LARGE'

export type ResumeFileError = {
code: ResumeFileErrorCode
message: string
maxBytes?: number
}

export function validateResumeFile(file: File): ResumeFileError | null {
if (file.size === 0) {
return { code: 'RESUME_EMPTY_FILE', message: '빈 파일은 업로드할 수 없습니다.' }
}
const isPdfMime = file.type === RESUME_ACCEPT_MIME
const isPdfExt = file.name.toLowerCase().endsWith(RESUME_ACCEPT_EXTENSION)
if (!isPdfMime && !isPdfExt) {
return {
code: 'RESUME_INVALID_FILE_TYPE',
message: 'PDF 파일만 업로드할 수 있습니다.',
}
}
if (file.size > RESUME_MAX_BYTES) {
return {
code: 'RESUME_FILE_TOO_LARGE',
message: `파일 크기가 ${Math.floor(RESUME_MAX_BYTES / 1024 / 1024)}MB를 초과합니다.`,
maxBytes: RESUME_MAX_BYTES,
}
}
return null
}

export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
20 changes: 20 additions & 0 deletions frontend/src/features/resume/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export type ResumeStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED'

export type ResumeResponse = {
id: number
originalFilename: string
fileSize: number
status: ResumeStatus
createdAt: string
updatedAt: string
}

export type PageResponse<T> = {
content: T[]
page: number
size: number
totalElements: number
totalPages: number
first: boolean
last: boolean
}
59 changes: 59 additions & 0 deletions frontend/src/features/resume/model/useResumeUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useRef, useState } from 'react'
import { isApiError } from '@/shared/api'
import { useUploadResumeMutation } from '@/features/resume/model/useResumes'
import {
RESUME_ACCEPT_EXTENSION,
RESUME_ACCEPT_MIME,
validateResumeFile,
} from '@/features/resume/lib/file-validation'

const RESUME_ACCEPT = `${RESUME_ACCEPT_MIME},${RESUME_ACCEPT_EXTENSION}`

type Options = {
onError?: (message: string) => void
}

export function useResumeUpload({ onError }: Options = {}) {
const inputRef = useRef<HTMLInputElement>(null)
const upload = useUploadResumeMutation()
const [error, setError] = useState<string | null>(null)

const fail = (message: string) => {
setError(message)
onError?.(message)
}

const handleFile = (file: File) => {
setError(null)
const invalid = validateResumeFile(file)
if (invalid) {
fail(invalid.message)
return
}
upload.mutate(file, {
onError: (err) => {
fail(
isApiError(err) ? err.message : '업로드 중 오류가 발생했습니다.',
)
},
})
}

const openPicker = () => inputRef.current?.click()

const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
e.target.value = ''
}

return {
inputRef,
accept: RESUME_ACCEPT,
error,
isPending: upload.isPending,
openPicker,
handleFile,
onInputChange,
}
}
64 changes: 64 additions & 0 deletions frontend/src/features/resume/model/useResumes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deleteResume,
getResume,
listResumes,
uploadResume,
} from '@/features/resume/api/resume'
import type {
PageResponse,
ResumeResponse,
} from '@/features/resume/model/types'

const RESUMES_KEY = 'resumes' as const
const POLL_INTERVAL_MS = 2_000

function hasInProgress(page: PageResponse<ResumeResponse> | undefined): boolean {
if (!page) return false
return page.content.some(
(r) => r.status === 'PENDING' || r.status === 'ANALYZING',
)
}

export function useResumesQuery(page = 0, size = 20) {
return useQuery({
queryKey: [RESUMES_KEY, page, size],
queryFn: () => listResumes(page, size),
refetchInterval: (query) =>
hasInProgress(query.state.data) ? POLL_INTERVAL_MS : false,
})
}

export function useResumeQuery(id: number | null) {
return useQuery({
queryKey: [RESUMES_KEY, 'detail', id],
queryFn: () => getResume(id as number),
enabled: id !== null,
refetchInterval: (query) => {
const status = query.state.data?.status
return status === 'PENDING' || status === 'ANALYZING'
? POLL_INTERVAL_MS
: false
},
})
}

export function useUploadResumeMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (file: File) => uploadResume(file),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [RESUMES_KEY] })
},
})
}

export function useDeleteResumeMutation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteResume(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [RESUMES_KEY] })
},
})
}
108 changes: 108 additions & 0 deletions frontend/src/features/resume/ui/ResumeDropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { useResumeUpload } from '@/features/resume/model/useResumeUpload'

const FAKE_PROGRESS_DURATION_MS = 1_500

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 곧 사라지겠군요


export function ResumeDropzone() {
const {
inputRef,
accept,
error,
isPending,
openPicker,
onInputChange,
handleFile,
} = useResumeUpload()
const [dragOver, setDragOver] = useState(false)
const progress = useFakeProgress(isPending)

const onDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file) handleFile(file)
}

return (
<div
role="button"
tabIndex={0}
aria-label="PDF 이력서 업로드"
aria-busy={isPending}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openPicker()
}
}}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={[
'relative rounded-xl border border-dashed p-10 text-center cursor-pointer transition-colors duration-fast',
dragOver
? 'border-primary bg-surface'
: 'border-border-strong bg-surface hover:border-primary/60',
isPending ? 'pointer-events-none' : '',
].join(' ')}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="sr-only"
onChange={onInputChange}
/>
<p className="text-body text-fg-strong font-semibold">
PDF 이력서를 드래그하거나 클릭해서 업로드
</p>
<p className="text-caption text-fg-subtle mt-2">
최대 20MB · PDF 형식만 지원
</p>
{error ? (
<p
role="alert"
className="text-caption text-danger-700 mt-4"
>
{error}
</p>
) : null}
{isPending ? (
<div className="mt-6">
<div className="h-1.5 w-full rounded-pill bg-sage-100 overflow-hidden">
<div
className="h-full bg-primary transition-[width] duration-fast"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-caption text-fg-muted mt-2">
업로드 중… {Math.floor(progress)}%
</p>
</div>
) : null}
</div>
)
}

function useFakeProgress(active: boolean): number {
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!active) {
setProgress(0)
return
}
const start = Date.now()
const id = window.setInterval(() => {
const elapsed = Date.now() - start
const p = Math.min(100, (elapsed / FAKE_PROGRESS_DURATION_MS) * 100)
setProgress(p)
if (p >= 100) window.clearInterval(id)
}, 60)
return () => window.clearInterval(id)
}, [active])
return progress
}
Loading