From c3a943a59171ff6fe9960ffd691b2b32cbd056f4 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:38:34 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(resume):=20Resume=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/resume/model/types.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 frontend/src/features/resume/model/types.ts diff --git a/frontend/src/features/resume/model/types.ts b/frontend/src/features/resume/model/types.ts new file mode 100644 index 00000000..ce1551ba --- /dev/null +++ b/frontend/src/features/resume/model/types.ts @@ -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 = { + content: T[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean +} From ebfa1a361c9b7710f04307500d9bfe87a47c677c Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:39:16 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(resume):=20resume=20API=20client=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/resume/api/resume.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 frontend/src/features/resume/api/resume.ts diff --git a/frontend/src/features/resume/api/resume.ts b/frontend/src/features/resume/api/resume.ts new file mode 100644 index 00000000..993438b4 --- /dev/null +++ b/frontend/src/features/resume/api/resume.ts @@ -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 { + const form = new FormData() + form.append('file', file) + const { data } = await apiClient.post('/api/resumes', form) + return data +} + +export async function listResumes( + page = 0, + size = 20, +): Promise> { + const { data } = await apiClient.get>( + '/api/resumes', { params: { page, size, sort: 'createdAt,desc' } }, + ) + return data +} + +export async function getResume(id: number): Promise { + const { data } = await apiClient.get(`/api/resumes/${id}`) + return data +} + +export async function deleteResume(id: number): Promise { + await apiClient.delete(`/api/resumes/${id}`) +} \ No newline at end of file From b87d2125a88ff66bf45330d812b787a85632f294 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:40:31 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(resume):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(=EC=9D=B4=EB=A0=A5=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/resume/lib/file-validation.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/features/resume/lib/file-validation.ts diff --git a/frontend/src/features/resume/lib/file-validation.ts b/frontend/src/features/resume/lib/file-validation.ts new file mode 100644 index 00000000..39fdc3db --- /dev/null +++ b/frontend/src/features/resume/lib/file-validation.ts @@ -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` +} From 0546e54053905e14d7e1bb33b76e864da3b08b78 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:41:04 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(resume):=20seResumes=20=ED=9B=85=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/resume/model/useResumes.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 frontend/src/features/resume/model/useResumes.ts diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts new file mode 100644 index 00000000..37bde3e7 --- /dev/null +++ b/frontend/src/features/resume/model/useResumes.ts @@ -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 | 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] }) + }, + }) +} From 088b1669606df414dcaa3a7fdab588b6a5c66f8b Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:41:51 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(resume):=20UploadResumeButton=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/resume/ui/UploadResumeButton.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/src/features/resume/ui/UploadResumeButton.tsx diff --git a/frontend/src/features/resume/ui/UploadResumeButton.tsx b/frontend/src/features/resume/ui/UploadResumeButton.tsx new file mode 100644 index 00000000..53a1e10f --- /dev/null +++ b/frontend/src/features/resume/ui/UploadResumeButton.tsx @@ -0,0 +1,69 @@ +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' + +type Props = { + onError?: (message: string) => void +} + +export function UploadResumeButton({ onError }: Props) { + const inputRef = useRef(null) + const upload = useUploadResumeMutation() + const [localError, setLocalError] = useState(null) + + const handle = (file: File) => { + setLocalError(null) + const invalid = validateResumeFile(file) + if (invalid) { + setLocalError(invalid.message) + onError?.(invalid.message) + return + } + upload.mutate(file, { + onError: (err) => { + const message = isApiError(err) + ? err.message + : '업로드 중 오류가 발생했습니다.' + setLocalError(message) + onError?.(message) + }, + }) + } + + const onChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handle(file) + e.target.value = '' + } + + return ( +
+ + + {localError ? ( +

+ {localError} +

+ ) : null} +
+ ) +} From 2cd2339fb36269e83f605a45fc44cf58601645c9 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:42:15 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(resume):=20ResumeDropzone=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/resume/ui/ResumeDropzone.tsx | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 frontend/src/features/resume/ui/ResumeDropzone.tsx diff --git a/frontend/src/features/resume/ui/ResumeDropzone.tsx b/frontend/src/features/resume/ui/ResumeDropzone.tsx new file mode 100644 index 00000000..28533996 --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeDropzone.tsx @@ -0,0 +1,132 @@ +import { useEffect, 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 FAKE_PROGRESS_DURATION_MS = 1_500 + +export function ResumeDropzone() { + const inputRef = useRef(null) + const [dragOver, setDragOver] = useState(false) + const [error, setError] = useState(null) + const upload = useUploadResumeMutation() + const progress = useFakeProgress(upload.isPending) + + const openPicker = () => inputRef.current?.click() + + const handleFile = (file: File) => { + setError(null) + const invalid = validateResumeFile(file) + if (invalid) { + setError(invalid.message) + return + } + upload.mutate(file, { + onError: (err) => { + setError( + isApiError(err) ? err.message : '업로드 중 오류가 발생했습니다.', + ) + }, + }) + } + + const onDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + } + + const onChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handleFile(file) + e.target.value = '' + } + + return ( +
{ + 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', + upload.isPending ? 'pointer-events-none' : '', + ].join(' ')} + > + +

+ PDF 이력서를 드래그하거나 클릭해서 업로드 +

+

+ 최대 20MB · PDF 형식만 지원 +

+ {error ? ( +

+ {error} +

+ ) : null} + {upload.isPending ? ( +
+
+
+
+

+ 업로드 중… {Math.floor(progress)}% +

+
+ ) : null} +
+ ) +} + +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 +} From 92d3e9b9578eeb2da120599c389fff40dd8ad776 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:42:54 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(resume):=20ResumeList=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/resume/ui/ResumeList.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 frontend/src/features/resume/ui/ResumeList.tsx diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx new file mode 100644 index 00000000..bae2f1ae --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -0,0 +1,173 @@ +import { isApiError } from '@/shared/api' +import { + useDeleteResumeMutation, + useResumesQuery, +} from '@/features/resume/model/useResumes' +import { formatFileSize } from '@/features/resume/lib/file-validation' +import type { + ResumeResponse, + ResumeStatus, +} from '@/features/resume/model/types' +import { ResumeDropzone } from './ResumeDropzone' + +export function ResumeList() { + const { data, isLoading, isError, error, refetch } = useResumesQuery() + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : '이력서 목록을 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const resumes = data?.content ?? [] + if (resumes.length === 0) return + + return ( +
    + {resumes.map((resume) => ( +
  • + +
  • + ))} +
+ ) +} + +function ResumeCard({ resume }: { resume: ResumeResponse }) { + const remove = useDeleteResumeMutation() + const handleDelete = () => { + const ok = window.confirm( + `'${resume.originalFilename}' 이력서를 삭제하시겠습니까?`, + ) + if (!ok) return + remove.mutate(resume.id) + } + return ( +
+ +
+

+ {resume.originalFilename} +

+

+ {formatFileSize(resume.fileSize)} · {relativeTime(resume.createdAt)} +

+
+ + +
+ ) +} + +function ResumeListSkeleton() { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} + +const STATUS_LABEL: Record = { + PENDING: '분석 대기', + ANALYZING: '분석 중', + ANALYZED: '분석 완료', + FAILED: '분석 실패', +} + +const STATUS_CLASS: Record = { + PENDING: 'bg-info-50 text-info-700', + ANALYZING: 'bg-info-50 text-info-700', + ANALYZED: 'bg-success-50 text-success-700', + FAILED: 'bg-danger-50 text-danger-700', +} + +function StatusBadge({ status }: { status: ResumeStatus }) { + const inProgress = status === 'PENDING' || status === 'ANALYZING' + return ( + + {inProgress ? : null} + {STATUS_LABEL[status]} + + ) +} + +function Spinner() { + return ( + + + + + ) +} + +function FileIcon() { + return ( +
+ PDF +
+ ) +} + +function relativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime() + if (diffMs < 0) return '방금 전' + const sec = diffMs / 1000 + if (sec < 60) return '방금 전' + const min = sec / 60 + if (min < 60) return `${Math.floor(min)}분 전` + const hr = min / 60 + if (hr < 24) return `${Math.floor(hr)}시간 전` + const day = hr / 24 + if (day < 7) return `${Math.floor(day)}일 전` + return new Date(iso).toLocaleDateString('ko-KR') +} From 98cfb885d24d23620ed33f7ffe6f8d08400702a7 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:43:09 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(resume):=20Resume=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/resume/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/features/resume/index.ts b/frontend/src/features/resume/index.ts index e69de29b..25e38e44 100644 --- a/frontend/src/features/resume/index.ts +++ b/frontend/src/features/resume/index.ts @@ -0,0 +1,7 @@ +export { ResumeList } from './ui/ResumeList' +export { UploadResumeButton } from './ui/UploadResumeButton' +export type { + PageResponse, + ResumeResponse, + ResumeStatus, +} from './model/types' From a084a7b632b87c611d70b005aca407a3a9f2656d Mon Sep 17 00:00:00 2001 From: Jaeho Date: Sat, 16 May 2026 22:44:12 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(workspace):=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=8B=A8=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Workspace/ui/WorkspacePage.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 3b964b3b..3bbc74e8 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,3 +1,4 @@ +import { ResumeList, UploadResumeButton } from '@/features/resume' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' import { WorkspaceProfileCard } from '@/widgets/workspace-profile-card' @@ -14,8 +15,9 @@ export default function WorkspacePage() { } > - + -

아직 등록된 이력서가 없습니다.

-

- 다음 단계에서 PDF 업로드가 가능해집니다. -

-
- ) -} - function EmptyRepoSlot() { return (
From 3bf7b681e7d03a5aba493fb562e3876698b68723 Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 20 May 2026 01:17:21 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat(resume):=20useResumeUpload=20?= =?UTF-8?q?=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/resume/model/useResumeUpload.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 frontend/src/features/resume/model/useResumeUpload.ts diff --git a/frontend/src/features/resume/model/useResumeUpload.ts b/frontend/src/features/resume/model/useResumeUpload.ts new file mode 100644 index 00000000..23860ed2 --- /dev/null +++ b/frontend/src/features/resume/model/useResumeUpload.ts @@ -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(null) + const upload = useUploadResumeMutation() + const [error, setError] = useState(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) => { + 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, + } +} From e8138ebbfa33bd7802457a167204f0fd3985a27e Mon Sep 17 00:00:00 2001 From: Jaeho Date: Wed, 20 May 2026 01:17:45 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor(resume):=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20useResumeUpload=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/resume/ui/ResumeDropzone.tsx | 58 ++++++------------- .../src/features/resume/ui/ResumeList.tsx | 1 + .../features/resume/ui/UploadResumeButton.tsx | 55 ++++-------------- 3 files changed, 29 insertions(+), 85 deletions(-) diff --git a/frontend/src/features/resume/ui/ResumeDropzone.tsx b/frontend/src/features/resume/ui/ResumeDropzone.tsx index 28533996..06598df3 100644 --- a/frontend/src/features/resume/ui/ResumeDropzone.tsx +++ b/frontend/src/features/resume/ui/ResumeDropzone.tsx @@ -1,38 +1,20 @@ -import { useEffect, 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' +import { useEffect, useState } from 'react' +import { useResumeUpload } from '@/features/resume/model/useResumeUpload' const FAKE_PROGRESS_DURATION_MS = 1_500 export function ResumeDropzone() { - const inputRef = useRef(null) + const { + inputRef, + accept, + error, + isPending, + openPicker, + onInputChange, + handleFile, + } = useResumeUpload() const [dragOver, setDragOver] = useState(false) - const [error, setError] = useState(null) - const upload = useUploadResumeMutation() - const progress = useFakeProgress(upload.isPending) - - const openPicker = () => inputRef.current?.click() - - const handleFile = (file: File) => { - setError(null) - const invalid = validateResumeFile(file) - if (invalid) { - setError(invalid.message) - return - } - upload.mutate(file, { - onError: (err) => { - setError( - isApiError(err) ? err.message : '업로드 중 오류가 발생했습니다.', - ) - }, - }) - } + const progress = useFakeProgress(isPending) const onDrop = (e: React.DragEvent) => { e.preventDefault() @@ -41,18 +23,12 @@ export function ResumeDropzone() { if (file) handleFile(file) } - const onChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) handleFile(file) - e.target.value = '' - } - return (
{ if (e.key === 'Enter' || e.key === ' ') { @@ -71,15 +47,15 @@ export function ResumeDropzone() { dragOver ? 'border-primary bg-surface' : 'border-border-strong bg-surface hover:border-primary/60', - upload.isPending ? 'pointer-events-none' : '', + isPending ? 'pointer-events-none' : '', ].join(' ')} >

PDF 이력서를 드래그하거나 클릭해서 업로드 @@ -95,7 +71,7 @@ export function ResumeDropzone() { {error}

) : null} - {upload.isPending ? ( + {isPending ? (
{ + // TODO: shared/ui/ConfirmDialog 도입 시 교체 (docs/ui-patterns.md §4) const ok = window.confirm( `'${resume.originalFilename}' 이력서를 삭제하시겠습니까?`, ) diff --git a/frontend/src/features/resume/ui/UploadResumeButton.tsx b/frontend/src/features/resume/ui/UploadResumeButton.tsx index 53a1e10f..881d6116 100644 --- a/frontend/src/features/resume/ui/UploadResumeButton.tsx +++ b/frontend/src/features/resume/ui/UploadResumeButton.tsx @@ -1,67 +1,34 @@ -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' +import { useResumeUpload } from '@/features/resume/model/useResumeUpload' type Props = { onError?: (message: string) => void } export function UploadResumeButton({ onError }: Props) { - const inputRef = useRef(null) - const upload = useUploadResumeMutation() - const [localError, setLocalError] = useState(null) - - const handle = (file: File) => { - setLocalError(null) - const invalid = validateResumeFile(file) - if (invalid) { - setLocalError(invalid.message) - onError?.(invalid.message) - return - } - upload.mutate(file, { - onError: (err) => { - const message = isApiError(err) - ? err.message - : '업로드 중 오류가 발생했습니다.' - setLocalError(message) - onError?.(message) - }, - }) - } - - const onChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) handle(file) - e.target.value = '' - } + const { inputRef, accept, error, isPending, openPicker, onInputChange } = + useResumeUpload({ onError }) return (
- {localError ? ( + {error ? (

- {localError} + {error}

) : null}