"use client"; import { useEffect, useId, useRef, useState, type ChangeEvent, type CSSProperties, type DragEvent, } from "react"; import { FoxFormLabel } 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; /** * `default`·`area`에서 목록을 보여준다. `image`에는 목록이 없다. * 넘기지 않으면 `area`이거나 여러 개를 받을 때만 켜진다 — 파일 하나는 칸이 곧 목록이다. */ showList?: boolean; /** 끌어다 놓기. `area`는 안내문이 그렇게 말하므로 이 값과 무관하게 항상 켜져 있다. */ droppable?: boolean; label?: string; 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`; } /** 비제어로 쓸 때 원본 `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: `${file.name}:${file.size}:${file.lastModified}`, 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, showList, droppable = false, label, 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([]); 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(); }; const takeFiles = (picked: File[]) => { if (!picked.length) { return; } onSelect?.(picked); if (files !== undefined) { return; } const made = picked.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 ?? [])); // 같은 파일을 다시 골라도 이벤트가 오게 비운다. event.target.value = ""; }; const handleRemove = (id: string) => { onRemove?.(id); 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}

    )}
  • ))}
)}
); }