diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 9b7d142..040928e 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { apiFetch, getAccessToken, setAccessToken, setAuthExpiredHandler } from './client' +import { + apiFetch, + apiFetchBlob, + getAccessToken, + setAccessToken, + setAuthExpiredHandler, +} from './client' import { ApiError } from './errors' function jsonResponse(body: unknown, init: ResponseInit = {}) { @@ -52,6 +58,20 @@ describe('apiFetch', () => { expect(headers.get('Authorization')).toBe('Bearer token-abc') }) + it('returns a binary response without JSON parsing and requests any content type', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(new Blob(['file-content']), { + headers: { 'Content-Type': 'application/pdf' }, + }), + ) + + const response = await apiFetchBlob('/files/file-1/content') + + expect((await response.blob()).size).toBeGreaterThan(0) + const [, init] = vi.mocked(fetch).mock.calls[0] + expect(new Headers(init?.headers).get('Accept')).toBe('*/*') + }) + it('returns undefined for 204 No Content', async () => { vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 204 })) diff --git a/src/api/client.ts b/src/api/client.ts index 6f8ab1e..3ba4440 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -70,7 +70,7 @@ async function parseErrorBody(response: Response, path: string): Promise { const headers = new Headers(init.headers) - headers.set('Accept', 'application/json') + if (!headers.has('Accept')) headers.set('Accept', 'application/json') if (init.body && !headers.has('Content-Type') && !(init.body instanceof FormData)) { headers.set('Content-Type', 'application/json') } @@ -90,7 +90,10 @@ export interface ApiFetchOptions extends RequestInit { skipAuthRetry?: boolean } -export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { +async function requestWithAuth( + path: string, + options: ApiFetchOptions = {}, +): Promise { const { skipAuthRetry, ...init } = options let response: Response @@ -119,6 +122,12 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): throw await parseErrorBody(response, path) } + return response +} + +export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { + const response = await requestWithAuth(path, options) + // 204는 물론이고, password-reset-requests처럼 body 없이 202만 내려주는 응답도 있어 // status 코드로만 분기하지 않고 실제 body가 비어있는지로 판단한다. const text = await response.text() @@ -128,3 +137,12 @@ export async function apiFetch(path: string, options: ApiFetchOptions = {}): return JSON.parse(text) as T } + +export function apiFetchBlob(path: string, options: ApiFetchOptions = {}): Promise { + const headers = new Headers(options.headers) + headers.set('Accept', '*/*') + return requestWithAuth(path, { + ...options, + headers, + }) +} diff --git a/src/api/files.test.ts b/src/api/files.test.ts index b62222a..0182d7b 100644 --- a/src/api/files.test.ts +++ b/src/api/files.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { uploadFile } from './files' +import { downloadFile, uploadFile } from './files' function jsonResponse(body: unknown) { return new Response(JSON.stringify(body), { status: 201, headers: { 'Content-Type': 'application/json' } }) @@ -30,3 +30,22 @@ describe('uploadFile', () => { expect(headers.has('Content-Type')).toBe(false) }) }) + +describe('downloadFile', () => { + it('downloads encoded file content and reads the server filename', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(new Blob(['pdf']), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': "attachment; filename*=UTF-8''passport%20copy.pdf", + }, + }), + ) + + const result = await downloadFile('file/1') + + expect(result.file_name).toBe('passport copy.pdf') + expect(result.blob.size).toBeGreaterThan(0) + expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain('/files/file%2F1/content') + }) +}) diff --git a/src/api/files.ts b/src/api/files.ts index 229da38..0de8c3d 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,7 +1,7 @@ -import { apiFetch } from './client' +import { apiFetch, apiFetchBlob } from './client' -// fowoco/server FileController 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드. -// 허용 형식은 image/jpeg·png·webp, application/pdf, 최대 20MB로 서버에 고정돼 있다. +// fowoco/server FileController/FileService 기준 — 분석·증빙·근로자 제출용 공통 파일 업로드. +// JPEG·PNG·WEBP·PDF와 유효한 HWP/HWPX 시그니처를 허용하며 최대 크기는 20MB다. export type ScanStatus = 'NOT_SCANNED' | 'CLEAN' | 'INFECTED' export interface FileUploadResponse { @@ -27,3 +27,31 @@ export function uploadFile(params: UploadFileParams): Promise('/files', { method: 'POST', body: formData }) } + +export interface FileDownloadResponse { + blob: Blob + file_name: string | null +} + +function getDownloadFileName(contentDisposition: string | null): string | null { + if (!contentDisposition) return null + + const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { + return decodeURIComponent(encoded) + } catch { + return encoded + } + } + + return contentDisposition.match(/filename="([^"]+)"/i)?.[1] ?? null +} + +export async function downloadFile(fileId: string): Promise { + const response = await apiFetchBlob(`/files/${encodeURIComponent(fileId)}/content`) + return { + blob: await response.blob(), + file_name: getDownloadFileName(response.headers.get('Content-Disposition')), + } +} diff --git a/src/pages/CaseDetailPage/CaseDetailPage.module.css b/src/pages/CaseDetailPage/CaseDetailPage.module.css index 2ae76e7..cde0847 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.module.css +++ b/src/pages/CaseDetailPage/CaseDetailPage.module.css @@ -387,6 +387,7 @@ .documentRow { display: flex; align-items: center; + flex-wrap: wrap; gap: 16px; padding: var(--fowoco-spacing-16) var(--fowoco-spacing-24); background: var(--surface-default); diff --git a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx index 02167db..9d5063f 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.test.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.test.tsx @@ -202,6 +202,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -309,6 +310,11 @@ describe('CaseDetailPage', () => { it('switches to the document tab and shows real document content', async () => { const user = userEvent.setup() + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1') + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickAnchor = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) mockTaskAndActivities({}, [], {}, [ { worker_document_id: 'doc-1', @@ -327,6 +333,12 @@ describe('CaseDetailPage', () => { expect(await screen.findByText('여권 사본')).toBeInTheDocument() expect(screen.getByText('완료')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: '다운로드' })) + + expect(createObjectUrl).toHaveBeenCalledTimes(1) + expect(clickAnchor).toHaveBeenCalledTimes(1) + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1') }) it('shows the document-readiness gate and saves a document request draft when documents are missing', async () => { diff --git a/src/pages/CaseDetailPage/CaseDetailPage.tsx b/src/pages/CaseDetailPage/CaseDetailPage.tsx index a469628..e9fe4dc 100644 --- a/src/pages/CaseDetailPage/CaseDetailPage.tsx +++ b/src/pages/CaseDetailPage/CaseDetailPage.tsx @@ -16,6 +16,7 @@ import { upsertDocumentRequestDraft, } from '../../api/documents' import { ApiError, getErrorMessage } from '../../api/errors' +import { downloadFile } from '../../api/files' import { cancelTask, fetchTaskById, updateChecklistItem } from '../../api/tasks' import { issueWorkerLink, resolveWorkerPortalUrl } from '../../api/workerLinks' import { AgentSourceLabel } from '../../components/ui/AgentSourceLabel/AgentSourceLabel' @@ -30,6 +31,7 @@ import { useApiQuery } from '../../hooks/useApiQuery' import { useToastStore } from '../../store/toastStore' import { ACTOR_TYPE_TO_AGENT_SOURCE, AUDIT_ACTION_LABEL } from '../../utils/auditLabels' import { formatEventTime } from '../../utils/datetime' +import { saveBlobAsFile } from '../../utils/fileDownload' import { TASK_SOURCE_LABEL, TASK_STATUS_LABEL, TASK_STATUS_TONE } from '../../utils/taskStatus' import { getDocumentViewModel } from '../../view-models/documentViewModel' import { getOperationalDateViewModel } from '../../view-models/dateViewModel' @@ -78,6 +80,7 @@ export function CaseDetailPage() { const [lastReissue, setLastReissue] = useState(null) const [issuedWorkerUrl, setIssuedWorkerUrl] = useState(null) const [issuedExpiresAt, setIssuedExpiresAt] = useState(null) + const [downloadingFileId, setDownloadingFileId] = useState(null) const moreMenuRef = useRef(null) const showToast = useToastStore((state) => state.showToast) @@ -273,6 +276,23 @@ export function CaseDetailPage() { } } + async function handleDownloadDocument(fileId: string, fallbackName: string) { + if (downloadingFileId) return + setDownloadingFileId(fileId) + try { + const downloaded = await downloadFile(fileId) + saveBlobAsFile(downloaded.blob, downloaded.file_name ?? fallbackName) + } catch (error) { + showToast( + error instanceof ApiError + ? getErrorMessage(error) + : '첨부 파일을 내려받지 못했습니다.', + ) + } finally { + setDownloadingFileId(null) + } + } + function handleOpenLinkReissue() { setLinkOverlay('reissue') } @@ -682,11 +702,22 @@ export function CaseDetailPage() {
{documents.map((document) => { const view = getDocumentViewModel(document) + const fileId = document.file_id return (
{view.typeLabel} {view.statusLabel} {view.expiry.display} + {fileId && ( + + )}
) })} diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx index f192cb3..df35387 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.test.tsx @@ -44,7 +44,7 @@ function renderPage(documentId: string) { } /> 서류 목록

} /> - 근로자 상세

} /> + 근로자 상세

} />
, ) @@ -55,6 +55,7 @@ beforeEach(() => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -77,6 +78,38 @@ describe('DocumentDetailPage', () => { expect(await screen.findByText('근로자 상세')).toBeInTheDocument() }) + it('downloads the attached original file through the authenticated file API', async () => { + const user = userEvent.setup() + const fileDocuments = [ + document({ + worker_document_id: 'D-1', + display_name: '응웬반A', + submission_status: 'SUBMITTED', + file_id: 'file-1', + }), + ] + vi.mocked(fetch) + .mockResolvedValueOnce(jsonResponse(pageResponse(fileDocuments))) + .mockResolvedValueOnce( + new Response(new Blob(['pdf']), { + headers: { 'Content-Disposition': 'attachment; filename="arc.pdf"' }, + }), + ) + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:file-1') + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickAnchor = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + renderPage('D-1') + + await user.click(await screen.findByRole('button', { name: '원본 다운로드' })) + + expect(createObjectUrl).toHaveBeenCalledTimes(1) + expect(clickAnchor).toHaveBeenCalledTimes(1) + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:file-1') + expect(String(vi.mocked(fetch).mock.calls[1][0])).toContain('/files/file-1/content') + }) + it('shows an empty state when the documentId does not match any document', async () => { vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(pageResponse(DOCUMENTS))) renderPage('does-not-exist') diff --git a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx index d599a44..3fee531 100644 --- a/src/pages/DocumentDetailPage/DocumentDetailPage.tsx +++ b/src/pages/DocumentDetailPage/DocumentDetailPage.tsx @@ -1,17 +1,22 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { fetchDocuments } from '../../api/documents' -import { getErrorMessage } from '../../api/errors' +import { ApiError, getErrorMessage } from '../../api/errors' +import { downloadFile } from '../../api/files' import { Button } from '../../components/ui/Button/Button' import { EmptyState } from '../../components/ui/EmptyState/EmptyState' import { StatusLabel } from '../../components/ui/StatusLabel/StatusLabel' import { useApiQuery } from '../../hooks/useApiQuery' +import { useToastStore } from '../../store/toastStore' +import { saveBlobAsFile } from '../../utils/fileDownload' import { getDocumentViewModel } from '../../view-models/documentViewModel' import styles from './DocumentDetailPage.module.css' export function DocumentDetailPage() { const { documentId } = useParams() const navigate = useNavigate() + const [downloading, setDownloading] = useState(false) + const showToast = useToastStore((state) => state.showToast) // GET /api/v1/documents/{id} 단건 조회가 없어서(#57 조사 결과), 목록을 통째로 받아 // worker_document_id로 찾는다. @@ -54,6 +59,24 @@ export function DocumentDetailPage() { } const view = getDocumentViewModel(document) + const fileId = document.file_id + + async function handleDownload() { + if (!fileId || downloading) return + setDownloading(true) + try { + const downloaded = await downloadFile(fileId) + saveBlobAsFile(downloaded.blob, downloaded.file_name ?? view.typeLabel) + } catch (downloadError) { + showToast( + downloadError instanceof ApiError + ? getErrorMessage(downloadError) + : '첨부 파일을 내려받지 못했습니다.', + ) + } finally { + setDownloading(false) + } + } return (
@@ -72,11 +95,19 @@ export function DocumentDetailPage() {

-

첨부 미리보기

- {/* TODO(backend): file_id로 실제 파일을 내려받는 API가 아직 없음 */} +

첨부 파일

{view.typeLabel}

-

{view.fileLabel} · 미리보기 API 연결 전입니다.

+

+ {fileId + ? '사업장 권한을 확인한 뒤 원본 파일을 내려받습니다.' + : '이 문서에는 연결된 파일이 없습니다.'} +

+ {fileId && ( + + )}
@@ -86,7 +117,7 @@ export function DocumentDetailPage() { diff --git a/src/pages/DocumentListPage/FileUploadModal.module.css b/src/pages/DocumentListPage/FileUploadModal.module.css index a843e02..7151012 100644 --- a/src/pages/DocumentListPage/FileUploadModal.module.css +++ b/src/pages/DocumentListPage/FileUploadModal.module.css @@ -116,6 +116,11 @@ cursor: pointer; } +.removeButton:disabled { + cursor: wait; + opacity: 0.5; +} + .actionRow { display: flex; justify-content: flex-end; diff --git a/src/pages/DocumentListPage/FileUploadModal.test.tsx b/src/pages/DocumentListPage/FileUploadModal.test.tsx index a20bf99..b78759a 100644 --- a/src/pages/DocumentListPage/FileUploadModal.test.tsx +++ b/src/pages/DocumentListPage/FileUploadModal.test.tsx @@ -10,16 +10,22 @@ function makeFile(name: string, sizeBytes: number, type = 'application/octet-str } beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ + file_id: 'file-server-1', + name: '계약서.hwpx', + mime_type: 'application/octet-stream', + size: 2048, + scan_status: 'NOT_SCANNED', + }), { status: 201, headers: { 'Content-Type': 'application/json' } }))) }) afterEach(() => { - vi.useRealTimers() + vi.unstubAllGlobals() }) describe('FileUploadModal', () => { it('uploads a valid HWPX file via the file picker and shows a file_id when done', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const user = userEvent.setup() render( {}} />) const input = screen.getByLabelText('HWP/HWPX 파일 선택') @@ -27,11 +33,12 @@ describe('FileUploadModal', () => { await user.upload(input, file) expect(screen.getByText('계약서.hwpx')).toBeInTheDocument() - expect(screen.getByText(/업로드 중/)).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(1000) - - expect(await screen.findByText(/업로드 완료 · file-/)).toBeInTheDocument() + expect(await screen.findByText(/업로드 완료 · file-server-1/)).toBeInTheDocument() + expect(fetch).toHaveBeenCalledTimes(1) + const [, init] = vi.mocked(fetch).mock.calls[0] + const formData = init?.body as FormData + expect(formData.get('file')).toBe(file) + expect(formData.get('purpose')).toBe('document_automation') }) it('rejects a file with an unsupported extension (bypassing the input accept filter via drag and drop)', () => { @@ -43,17 +50,18 @@ describe('FileUploadModal', () => { expect(screen.getByText(/지원하지 않는 파일 형식입니다/)).toBeInTheDocument() }) - it('rejects a file larger than 10MB', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + it('rejects a file larger than 20MB', async () => { + const user = userEvent.setup() render( {}} />) const input = screen.getByLabelText('HWP/HWPX 파일 선택') - await user.upload(input, makeFile('큰파일.hwp', 11 * 1024 * 1024)) + await user.upload(input, makeFile('큰파일.hwp', 21 * 1024 * 1024)) expect(screen.getByText(/파일이 너무 큽니다/)).toBeInTheDocument() + expect(fetch).not.toHaveBeenCalled() }) - it('accepts a dropped file via drag and drop', () => { + it('accepts a dropped file via drag and drop', async () => { render( {}} />) const dropzone = screen.getByText('여기로 파일을 끌어다 놓거나').closest('div')! @@ -61,23 +69,25 @@ describe('FileUploadModal', () => { fireEvent.drop(dropzone, { dataTransfer: { files: [file] } }) expect(screen.getByText('안내문.hwp')).toBeInTheDocument() + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) }) it('removes a file from the list', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const user = userEvent.setup() render( {}} />) const input = screen.getByLabelText('HWP/HWPX 파일 선택') await user.upload(input, makeFile('삭제할파일.hwp', 1024)) expect(screen.getByText('삭제할파일.hwp')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: '삭제' })) + await screen.findByText(/업로드 완료/) + await user.click(screen.getByRole('button', { name: '목록에서 제거' })) expect(screen.queryByText('삭제할파일.hwp')).not.toBeInTheDocument() }) it('closes via the 닫기 button', async () => { - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const user = userEvent.setup() const onClose = vi.fn() render() @@ -92,16 +102,23 @@ describe('FileUploadModal', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) - it('waits for pending upload timers before letting Vitest tear down the test', async () => { - // useEffect cleanup in the component clears pending timers on unmount; this test just - // asserts nothing throws when a component with an in-flight upload unmounts. - const { unmount } = render( {}} />) + it('shows the server error and allows removing a failed upload', async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ + timestamp: '2026-08-08T00:00:00Z', + status: 415, + code: 'UNSUPPORTED_FILE_TYPE', + message: '지원하지 않는 파일입니다.', + path: '/api/v1/files', + request_id: 'request-1', + field_errors: [], + }), { status: 415, headers: { 'Content-Type': 'application/json' } })) + const user = userEvent.setup() + render( {}} />) const input = screen.getByLabelText('HWP/HWPX 파일 선택') - const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - await user.upload(input, makeFile('언마운트.hwp', 1024)) - - unmount() + await user.upload(input, makeFile('손상파일.hwp', 1024)) - await waitFor(() => {}) + expect(await screen.findByText(/지원하지 않는 파일입니다/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '목록에서 제거' })) + expect(screen.queryByText('손상파일.hwp')).not.toBeInTheDocument() }) }) diff --git a/src/pages/DocumentListPage/FileUploadModal.tsx b/src/pages/DocumentListPage/FileUploadModal.tsx index ebe2594..40c9ac1 100644 --- a/src/pages/DocumentListPage/FileUploadModal.tsx +++ b/src/pages/DocumentListPage/FileUploadModal.tsx @@ -1,11 +1,9 @@ -import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from 'react' +import { useRef, useState, type ChangeEvent, type DragEvent } from 'react' +import { ApiError, getErrorMessage } from '../../api/errors' +import { uploadFile } from '../../api/files' import { Modal } from '../../components/ui/Modal/Modal' import styles from './FileUploadModal.module.css' -// TODO(backend): server에 파일 업로드 API가 아직 없다(#158 조사 결과 — MultipartFile을 받는 -// 컨트롤러가 fowoco/server에 전혀 없음). API가 생기면 이 setTimeout 시뮬레이션을 -// POST /api/v1/files(multipart)로 교체하고, 반환된 file_id를 문서 분석/AiRun 로직에 전달한다. - type UploadStatus = 'uploading' | 'done' | 'error' interface UploadEntry { @@ -18,8 +16,7 @@ interface UploadEntry { } const ALLOWED_EXTENSIONS = ['.hwp', '.hwpx'] -const MAX_SIZE_BYTES = 10 * 1024 * 1024 -const UPLOAD_DELAY_MS = 900 +const MAX_SIZE_BYTES = 20 * 1024 * 1024 function isAllowedFile(file: File): boolean { const name = file.name.toLowerCase() @@ -41,14 +38,25 @@ export function FileUploadModal({ open, onClose }: FileUploadModalProps) { const [entries, setEntries] = useState([]) const [dragActive, setDragActive] = useState(false) const inputRef = useRef(null) - const timers = useRef[]>([]) - useEffect(() => { - const pendingTimers = timers.current - return () => { - for (const timer of pendingTimers) clearTimeout(timer) + async function uploadEntry(file: File, id: string) { + try { + const uploaded = await uploadFile({ file, purpose: 'document_automation' }) + setEntries((prev) => + prev.map((entry) => + entry.id === id ? { ...entry, status: 'done', fileId: uploaded.file_id } : entry, + ), + ) + } catch (error) { + const errorMessage = + error instanceof ApiError ? getErrorMessage(error) : '파일을 업로드하지 못했습니다. 다시 시도해 주세요.' + setEntries((prev) => + prev.map((entry) => + entry.id === id ? { ...entry, status: 'error', errorMessage } : entry, + ), + ) } - }, []) + } function addFiles(files: FileList | null) { if (!files) return @@ -73,15 +81,7 @@ export function FileUploadModal({ open, onClose }: FileUploadModalProps) { } setEntries((prev) => [...prev, { id, name: file.name, size: file.size, status: 'uploading' }]) - - const timer = setTimeout(() => { - setEntries((prev) => - prev.map((entry) => - entry.id === id ? { ...entry, status: 'done', fileId: `file-${Math.random().toString(36).slice(2, 10)}` } : entry, - ), - ) - }, UPLOAD_DELAY_MS) - timers.current.push(timer) + void uploadEntry(file, id) } } @@ -123,7 +123,7 @@ export function FileUploadModal({ open, onClose }: FileUploadModalProps) { -

HWP, HWPX · 최대 10MB

+

HWP, HWPX · 최대 20MB

{entry.name}

-

+

{formatFileSize(entry.size)} {entry.status === 'uploading' && ' · 업로드 중...'} {entry.status === 'done' && ` · 업로드 완료 · ${entry.fileId}`} @@ -158,8 +158,13 @@ export function FileUploadModal({ open, onClose }: FileUploadModalProps) { }`} aria-hidden="true" /> - ))} diff --git a/src/utils/fileDownload.ts b/src/utils/fileDownload.ts new file mode 100644 index 0000000..4dbe690 --- /dev/null +++ b/src/utils/fileDownload.ts @@ -0,0 +1,11 @@ +export function saveBlobAsFile(blob: Blob, fileName: string) { + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = fileName + anchor.hidden = true + document.body.append(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(objectUrl) +}