feat: Enhance image handling in table components with improved loading and error states

- Introduced a new TableCellImage component for rendering images in table cells, supporting both object IDs and direct URLs.
- Implemented loading and error states for images, providing a better user experience when images fail to load.
- Updated CardModeRenderer and SingleTableWithSticky components to utilize the new image handling logic, ensuring consistent image rendering across the application.
- Enhanced formatCellValue function to return React nodes, allowing for more flexible cell content rendering.
This commit is contained in:
kjs 2026-02-10 18:30:15 +09:00
parent 5b44a41651
commit 9785f098d8
3 changed files with 140 additions and 47 deletions

View File

@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import { CardDisplayConfig, ColumnConfig } from "./types"; import { CardDisplayConfig, ColumnConfig } from "./types";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
interface CardModeRendererProps { interface CardModeRendererProps {
data: Record<string, any>[]; data: Record<string, any>[];
@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
{imageValue && ( {imageValue && (
<div className="mb-3"> <div className="mb-3">
<img <img
src={imageValue} src={(() => {
const strValue = String(imageValue);
const isObjid = /^\d+$/.test(strValue);
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
})()}
alt={titleValue} alt={titleValue}
className="h-24 w-full rounded-md bg-gray-100 object-cover" className="h-24 w-full rounded-md bg-gray-100 object-cover"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
// 이미지 로드 실패 시 폴백 표시
target.style.display = "none"; target.style.display = "none";
const parent = target.parentElement;
if (parent && !parent.querySelector("[data-image-fallback]")) {
const fallback = document.createElement("div");
fallback.setAttribute("data-image-fallback", "true");
fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground";
fallback.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>`;
parent.appendChild(fallback);
}
}} }}
/> />
</div> </div>

View File

@ -24,7 +24,7 @@ interface SingleTableWithStickyProps {
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void; handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode; renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode; renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string; formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => React.ReactNode;
getColumnWidth: (column: ColumnConfig) => number; getColumnWidth: (column: ColumnConfig) => number;
containerWidth?: string; // 컨테이너 너비 설정 containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean; loading?: boolean;
@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
currentSearchIndex < highlightArray.length && currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey; highlightArray[currentSearchIndex] === cellKey;
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
const rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 이미지 등 JSX 반환 여부 확인
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
// 셀 값에서 검색어 하이라이트 렌더링 // 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => { const renderCellContent = () => {
const cellValue = // ReactNode(JSX)가 반환된 경우 (이미지 등) 그대로 렌더링
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; if (isReactElement) {
return rawCellValue;
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
} }
// 검색어 하이라이트 처리 if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
const lowerValue = String(cellValue).toLowerCase(); return rawCellValue;
}
// 검색어 하이라이트 처리 (문자열만)
const strValue = String(rawCellValue);
const lowerValue = strValue.toLowerCase();
const lowerTerm = searchTerm.toLowerCase(); const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm); const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue; if (startIndex === -1) return rawCellValue;
const before = String(cellValue).slice(0, startIndex); const before = strValue.slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); const match = strValue.slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length); const after = strValue.slice(startIndex + searchTerm.length);
return ( return (
<> <>
@ -307,7 +316,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={`cell-${column.columnName}`} key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined} id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn( className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm", "text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
!isReactElement && "whitespace-nowrap",
`text-${column.align}`, `text-${column.align}`,
// 고정 컬럼 스타일 // 고정 컬럼 스타일
column.fixed === "left" && column.fixed === "left" &&
@ -322,9 +333,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
minWidth: "100px", // 최소 너비 보장 minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한 maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", // 이미지 셀은 overflow 허용
textOverflow: "ellipsis", ...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
whiteSpace: "nowrap",
// sticky 위치 설정 // sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),

View File

@ -12,6 +12,96 @@ import { getFilePreviewUrl } from "@/lib/api/file";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
// objid인 경우: 인증된 API로 blob 다운로드
const loadImage = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${strValue}`, {
responseType: "blob",
});
if (mounted) {
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
setImgSrc(url);
setLoading(false);
}
} catch {
if (mounted) {
setError(true);
setLoading(false);
}
}
};
loadImage();
} else {
// 경로인 경우: 직접 URL 사용
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
}
return () => {
mounted = false;
// blob URL 해제
if (imgSrc && imgSrc.startsWith("blob:")) {
window.URL.revokeObjectURL(imgSrc);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
</div>
);
}
if (error || !imgSrc) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<img
src={imgSrc}
alt="이미지"
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
window.open(openUrl, "_blank");
}}
onError={() => setError(true)}
/>
</div>
);
});
TableCellImage.displayName = "TableCellImage";
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 // 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global { declare global {
interface Window { interface Window {
@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType; const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시 // 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용)
if (inputType === "image" && value) { if (inputType === "image" && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용 return <TableCellImage value={String(value)} />;
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
// 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지)
const imageUrl = isObjid
? getFilePreviewUrl(strValue)
: getFullImageUrl(strValue);
return (
<div className="flex justify-center">
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// 이미지 클릭 시 새 탭에서 크게 보기
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
} }
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시 // 📎 첨부파일 타입: 파일 아이콘과 개수 표시
@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td <td
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm", "text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? "px-0 py-1" ? "px-0 py-1"
: "px-2 py-1 sm:px-4 sm:py-1.5", : "px-2 py-1 sm:px-4 sm:py-1.5",
@ -6112,7 +6178,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data-row={index} data-row={index}
data-col={colIndex} data-col={colIndex}
className={cn( className={cn(
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm", "text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
// 🆕 포커스된 셀 스타일 // 🆕 포커스된 셀 스타일