4a8413000b
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
250 lines
8.4 KiB
TypeScript
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";
|