Files
invyone/frontend/lib/registry/components/input/file-picker.tsx
T
DDD1542 4a8413000b
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Consolidate canonical input migration
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
2026-05-12 18:36:43 +09:00

250 lines
8.4 KiB
TypeScript

"use client";
/**
* FilePicker — canonical input 의 file 분기 picker.
*
* 옛 file-upload / v2-file-upload / v2-media / image-widget 의 본체 import 없이
* 브라우저 File 선택 + 선택된 파일 표시(파일명 / 이미지 미리보기) + 삭제만 처리한다.
* 업로드/저장 API 통합은 별도 phase. 이 컴포넌트는 InputComponent 의 propagate 흐름
* (onChange) 를 그대로 타며, 값은 `File | File[]` 또는 기존 URL 문자열 모두 받는다.
*
* Props:
* - value: File | File[] | string | string[] | undefined
* - onChange(value): 부모 알림. multiple 이면 배열, 아니면 단일
* - accept: input[type=file] accept 와 동일
* - multiple: true 면 다중 선택
* - maxFiles: 다중 시 최대 개수 (초과 자르기)
* - disabled / readonly: 선택/삭제 모두 비활성
* - placeholder: 빈 상태 안내 텍스트
* - showPreview: image 미리보기 (accept 가 image/* 이거나 명시 시 자동)
*
* value 표시 규칙:
* - File 객체: file.name 또는 URL.createObjectURL preview (image)
* - 문자열: 그대로 URL/이름으로 표시 (서버 저장된 path 호환)
*/
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Upload, X, FileText, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FilePickerProps {
value?: File | string | Array<File | string> | null;
onChange?: (value: File | string | Array<File | string> | null) => void;
accept?: string;
multiple?: boolean;
maxFiles?: number;
disabled?: boolean;
readonly?: boolean;
placeholder?: string;
showPreview?: boolean;
className?: string;
}
type DisplayItem = {
key: string;
label: string;
previewUrl?: string;
source: File | string;
isImage: boolean;
};
function detectIsImage(name: string | undefined, accept: string | undefined): boolean {
if (!name) return !!accept && accept.startsWith("image/");
const lower = name.toLowerCase();
if (lower.match(/\.(png|jpe?g|gif|webp|svg|bmp|avif)$/)) return true;
return !!accept && accept.startsWith("image/");
}
function toArray(value: FilePickerProps["value"]): Array<File | string> {
if (value == null) return [];
if (Array.isArray(value)) return value.filter((v): v is File | string => !!v);
return [value];
}
function isFileValue(value: File | string): value is File {
return typeof File !== "undefined" && value instanceof File;
}
export const FilePicker = React.forwardRef<HTMLDivElement, FilePickerProps>(
(
{
value,
onChange,
accept,
multiple,
maxFiles,
disabled,
readonly,
placeholder,
showPreview,
className,
},
ref,
) => {
const inputRef = useRef<HTMLInputElement>(null);
const lockEdit = !!(disabled || readonly);
const items = useMemo<DisplayItem[]>(() => {
const arr = toArray(value);
return arr.map((item, idx) => {
if (isFileValue(item)) {
const isImage = detectIsImage(item.name, accept);
return {
key: `${item.name}-${item.size}-${item.lastModified}-${idx}`,
label: item.name,
previewUrl: isImage ? URL.createObjectURL(item) : undefined,
source: item,
isImage,
};
}
// 문자열 (URL 또는 파일명) — 서버 저장된 경로 호환
const str = String(item);
const fileName = str.split("/").pop() || str;
const isImage = detectIsImage(fileName, accept);
return {
key: `${str}-${idx}`,
label: fileName,
previewUrl: isImage ? str : undefined,
source: str,
isImage,
};
});
}, [value, accept]);
// File preview URL revoke (메모리 누수 방지)
useEffect(() => {
const urls = items
.filter((it) => isFileValue(it.source) && it.previewUrl)
.map((it) => it.previewUrl!);
return () => {
urls.forEach((u) => URL.revokeObjectURL(u));
};
}, [items]);
const handleSelectFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (lockEdit) return;
const picked = Array.from(e.target.files || []);
if (picked.length === 0) return;
if (!multiple) {
onChange?.(picked[0]);
} else {
const existing = toArray(value);
const combined: Array<File | string> = [...existing, ...picked];
const limited = maxFiles && maxFiles > 0 ? combined.slice(0, maxFiles) : combined;
onChange?.(limited);
}
// 같은 파일 다시 선택 가능하도록 input value 초기화
if (inputRef.current) inputRef.current.value = "";
};
const handleRemove = (key: string) => {
if (lockEdit) return;
if (!multiple) {
onChange?.(null);
return;
}
const next = items.filter((it) => it.key !== key).map((it) => it.source);
onChange?.(next);
};
const wantPreview = showPreview ?? (!!accept && accept.startsWith("image/"));
const atLimit = multiple && maxFiles && items.length >= maxFiles;
const openPicker = () => {
if (lockEdit || atLimit) return;
inputRef.current?.click();
};
return (
<div
ref={ref}
className={cn(
"flex h-full w-full flex-col gap-1 overflow-auto px-1 py-1 text-sm",
lockEdit && "cursor-not-allowed",
className,
)}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={!!multiple}
onChange={handleSelectFiles}
disabled={lockEdit}
className="hidden"
tabIndex={-1}
/>
{items.length === 0 ? (
<button
type="button"
onClick={openPicker}
disabled={lockEdit}
className={cn(
"flex h-full w-full items-center justify-center gap-1.5 rounded-sm border border-dashed border-border bg-background px-2 py-1 text-xs text-muted-foreground",
lockEdit ? "cursor-not-allowed opacity-60" : "hover:bg-muted/40",
)}
>
<Upload className="h-3.5 w-3.5" />
<span>{placeholder || (wantPreview ? "이미지 선택" : "파일 선택")}</span>
</button>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{items.map((it) => (
<div
key={it.key}
className="flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-xs"
>
{wantPreview && it.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.previewUrl}
alt={it.label}
className="h-6 w-6 rounded-sm object-cover"
/>
) : it.isImage ? (
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="max-w-[160px] truncate" title={it.label}>
{it.label}
</span>
{!lockEdit && (
<button
type="button"
onClick={() => handleRemove(it.key)}
className="ml-0.5 text-muted-foreground hover:text-foreground"
title="제거"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{(multiple || items.length === 0) && (
<button
type="button"
onClick={openPicker}
disabled={lockEdit || !!atLimit}
title={atLimit ? `최대 ${maxFiles}개까지` : "추가"}
className={cn(
"flex items-center gap-1 rounded-sm border border-dashed border-border bg-background px-1.5 py-0.5 text-xs text-muted-foreground",
(lockEdit || atLimit) ? "cursor-not-allowed opacity-50" : "hover:bg-muted/40",
)}
>
<Upload className="h-3 w-3" />
<span>{multiple ? "추가" : "교체"}</span>
</button>
)}
</div>
</>
)}
</div>
);
},
);
FilePicker.displayName = "FilePicker";