"use client"; import { useEffect, useId, useRef, useState, type ChangeEvent, type CSSProperties, type DragEvent, } from "react"; import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label"; import { cx } from "../../utils"; import { FoxButton } from "../fox-button"; import { FoxIconButton } from "../fox-icon-button"; import { FoxInput } from "../fox-input"; import { FoxTextButton } from "../fox-text-button"; export type FoxFileUploadMode = "default" | "area" | "image"; /** 목록에 그릴 파일 한 줄. 업로드 상태는 호출부가 이 모양으로 바꿔서 넘긴다. */ export interface FoxFileItem { /** 목록에서 구분하는 키. */ id: string; /** 확장자를 뺀 이름. 길면 말줄임된다. */ name: string; /** 이름 뒤에 붙는 표기 — 시안은 `.PDF (20KB)`. 줄어들지 않는다. */ meta?: string; /** 참이면 줄이 붉어지고 `message`가 아래에 붙는다. */ error?: boolean; message?: string; /** image 모드에서 썸네일로 쓸 주소. */ previewUrl?: string; } export interface FoxFileUploadProps { mode?: FoxFileUploadMode; /** 주면 제어 컴포넌트가 된다. 없으면 고른 파일을 안에서 들고 있는다. */ files?: FoxFileItem[]; defaultFiles?: FoxFileItem[]; /** 목록이 바뀔 때(고르기·삭제) 부른다. */ onFilesChange?: (files: FoxFileItem[]) => void; /** 파일을 고르면 원본 `File`을 그대로 넘긴다 — 업로드는 호출부가 한다. */ onSelect?: (picked: File[]) => void; onRemove?: (id: string) => void; accept?: string; multiple?: boolean; /** * 네이티브 파일 입력의 `name`. 주면 고른 파일이 감싼 `
`의 제출에 그대로 실린다 — * 값을 따로 들고 있지 않아도 서버로 넘어간다. */ name?: string; /** * `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다. * 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다. */ showList?: boolean; /** 끌어다 놓기. `area`는 안내문이 그렇게 말하므로 이 값과 무관하게 항상 켜져 있다. */ droppable?: boolean; label?: string; /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxInput`·`FoxSelect`와 같은 규약). */ requirement?: FoxFormLabelRequirement; description?: string; placeholder?: string; areaText?: string; selectLabel?: string; removeLabel?: string; /** `default` 모드 입력칸 폭. 시안은 320px이고 토큰에 없어 여기서 받는다. */ inputWidth?: string | number; /** `image` 모드 썸네일 크기. 시안은 213×120px(16:9)이고 토큰에 없어 여기서 받는다. */ thumbnailWidth?: string | number; thumbnailHeight?: string | number; disabled?: boolean; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. */ className?: string; } const DEFAULT_AREA_TEXT = "첨부할 파일을 여기에 끌어다 놓거나 파일 선택 버튼을 직접 선택해주세요."; /* ── 아이콘 ──────────────────────────────────────────────────────────────── @fox에 아이콘 세트가 없어 시안의 모양을 직접 그린다. 세트가 들어오면 교체할 자리다. 전부 `currentColor`라 감싼 쪽이 색을 정한다. */ function UploadIcon() { return ( ); } function PaperclipIcon() { return ( ); } function XCircleIcon() { return ( ); } function ProhibitIcon() { return ( ); } function CameraPlusIcon() { return ( ); } function TrashIcon() { return ( ); } /* ── 값 다루기 ─────────────────────────────────────────────────────────────── */ function formatBytes(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } if (bytes < 1024 * 1024) { return `${Math.round(bytes / 1024)}KB`; } return `${(bytes / 1024 / 1024).toFixed(1)}MB`; } /** 목록 항목의 id. 삭제할 때 같은 규칙으로 원본 `File`을 되찾으므로 한곳에 둔다. */ function fileKey(file: File): string { return `${file.name}:${file.size}:${file.lastModified}`; } /** 비제어로 쓸 때 원본 `File`을 목록 한 줄로 바꾼다. 시안의 `이름` + `.PDF (20KB)` 모양이다. */ function toItem(file: File, previewUrl?: string): FoxFileItem { const dot = file.name.lastIndexOf("."); const base = dot > 0 ? file.name.slice(0, dot) : file.name; const ext = dot > 0 ? file.name.slice(dot + 1).toUpperCase() : ""; return { id: fileKey(file), name: base, meta: ext ? `.${ext} (${formatBytes(file.size)})` : `(${formatBytes(file.size)})`, previewUrl, }; } function toSize(value: string | number | undefined): string | undefined { if (value === undefined) { return undefined; } return typeof value === "number" ? `${value}px` : value; } /** * @fox 파일 첨부. 세 모드를 한 컴포넌트로 둔다 — 고르는 방식만 다르고 목록은 같기 때문이다. * * 업로드는 하지 않는다. 파일을 고르면 원본 `File`을 넘길 뿐이고, 진행률·서버 오류를 목록에 * 어떻게 비출지는 호출부가 `files`로 정한다. `files`를 넘기지 않으면 고른 파일을 안에서 * 들고 있어 간단한 폼에서는 그대로 쓸 수 있다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxFileUpload({ mode = "default", files, defaultFiles, onFilesChange, onSelect, onRemove, accept, multiple = false, name, showList, droppable = false, label, requirement, description, placeholder = "파일을 선택해 주세요.", areaText = DEFAULT_AREA_TEXT, selectLabel = "파일선택", removeLabel = "삭제", inputWidth = 320, thumbnailWidth = 213, thumbnailHeight = 120, disabled = false, hidden = false, className, }: FoxFileUploadProps) { const autoId = useId(); const pickerRef = useRef(null); const objectUrls = useRef([]); /** 지금 고른 원본 파일들. 네이티브 입력의 `files`를 다시 세우는 근거다(아래 `syncPicker`). */ const picked = useRef([]); const [inner, setInner] = useState(defaultFiles ?? []); const [over, setOver] = useState(false); // 비제어로 만든 미리보기 주소는 사라질 때 돌려준다. useEffect( () => () => { objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); }, [] ); if (hidden) { return null; } const current = files ?? inner; // area는 안내문이 끌어다 놓으라고 말하므로 언제나 받는다. const dropEnabled = !disabled && (mode === "area" || droppable); const listVisible = (showList ?? (mode === "area" || multiple)) && mode !== "image"; const preview = current[0]; // 시안의 upload=true 모양 — 목록 없이 파일 하나를 들고 있으면 칸이 잠기고 버튼이 삭제로 바뀐다. const singleFilled = mode === "default" && !listVisible && current.length === 1; const openPicker = () => { pickerRef.current?.click(); }; /** * 고른 파일을 네이티브 입력에 **다시 심는다.** * * 폼 제출에 실리는 것은 React 상태가 아니라 이 입력의 `files`다. 끌어다 놓은 파일은 애초에 * 입력을 거치지 않고, 고른 파일도 예전에는 "같은 파일을 다시 골라도 이벤트가 오게" 바로 * 비웠다 — 그래서 `name`을 줘도 폼에는 아무것도 실리지 않았다(그 prop의 설명과 반대였다). * 비우는 대신 지금 목록으로 덮으면 두 가지가 같이 해결된다: 제출에 실리고, 목록을 비웠을 때는 * 입력도 비어 같은 파일을 다시 고를 수 있다. */ const syncPicker = (nextFiles: File[]) => { const picker = pickerRef.current; // jsdom 등 DataTransfer가 없는 환경에서는 조용히 건너뛴다 — 화면 동작은 그대로다. if (!picker || typeof DataTransfer === "undefined") { return; } const transfer = new DataTransfer(); nextFiles.forEach((file) => transfer.items.add(file)); picker.files = transfer.files; }; const takeFiles = (chosen: File[]) => { if (!chosen.length) { return; } onSelect?.(chosen); // 제어·비제어를 가리지 않고 입력은 늘 맞춘다 — `name`은 목록을 누가 들고 있든 "고른 파일이 // 폼에 실린다"는 약속이다. picked.current = multiple ? [...picked.current, ...chosen] : chosen.slice(0, 1); syncPicker(picked.current); if (files !== undefined) { return; } const made = chosen.map((file) => { if (!file.type.startsWith("image/")) { return toItem(file); } const url = URL.createObjectURL(file); objectUrls.current.push(url); return toItem(file, url); }); const next = multiple ? [...inner, ...made] : made.slice(0, 1); setInner(next); onFilesChange?.(next); }; const handlePicked = (event: ChangeEvent) => { takeFiles(Array.from(event.target.files ?? [])); }; const handleRemove = (id: string) => { onRemove?.(id); // 밖에서 받은 기존 파일(수정 화면의 등록된 이미지)은 여기 없어 그대로 지나간다. picked.current = picked.current.filter((file) => fileKey(file) !== id); syncPicker(picked.current); if (files !== undefined) { return; } const next = inner.filter((item) => item.id !== id); setInner(next); onFilesChange?.(next); }; const handleDragOver = (event: DragEvent) => { if (!dropEnabled) { return; } event.preventDefault(); setOver(true); }; const handleDragLeave = () => { setOver(false); }; const handleDrop = (event: DragEvent) => { if (!dropEnabled) { return; } event.preventDefault(); setOver(false); takeFiles(Array.from(event.dataTransfer.files ?? [])); }; const thumbStyle: CSSProperties = { inlineSize: toSize(thumbnailWidth), blockSize: toSize(thumbnailHeight), }; const selectButton = (type: "primary" | "secondary") => ( } disabled={disabled} onAction={openPicker} /> ); return (
{label && ( {label} )} {/* 파일 고르기는 네이티브 입력이 맡고 화면에는 버튼만 보인다. */} {mode === "default" && (
{singleFilled ? ( } disabled={disabled} onAction={() => handleRemove(preview.id)} /> ) : ( selectButton("secondary") )}
)} {mode === "area" && (

{areaText}

{selectButton("primary")}
)} {mode === "image" && (
{preview?.previewUrl ? ( <> {/* eslint-disable-next-line @next/next/no-img-element -- next/image를 쓰면 이 폴더가 Next 전용이 된다. */} } disabled={disabled} onAction={() => handleRemove(preview.id)} /> ) : ( )}
{selectButton("secondary")} {description &&

{description}

}
)} {listVisible && current.length > 0 && (
    {current.map((item) => (
  • {item.name} {item.meta && {item.meta}} } disabled={disabled} onAction={() => handleRemove(item.id)} />
    {item.error && item.message && (

    {item.message}

    )}
  • ))}
)}
); }