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:
@@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
|
||||
import { CardDisplayConfig, ColumnConfig } from "./types";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
|
||||
interface CardModeRendererProps {
|
||||
data: Record<string, any>[];
|
||||
@@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
||||
{imageValue && (
|
||||
<div className="mb-3">
|
||||
<img
|
||||
src={imageValue}
|
||||
src={(() => {
|
||||
const strValue = String(imageValue);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||
})()}
|
||||
alt={titleValue}
|
||||
className="h-24 w-full rounded-md bg-gray-100 object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
// 이미지 로드 실패 시 폴백 표시
|
||||
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>
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SingleTableWithStickyProps {
|
||||
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => 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;
|
||||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
@@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
currentSearchIndex < highlightArray.length &&
|
||||
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 cellValue =
|
||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return cellValue;
|
||||
// ReactNode(JSX)가 반환된 경우 (이미지 등) 그대로 렌더링
|
||||
if (isReactElement) {
|
||||
return rawCellValue;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트 처리
|
||||
const lowerValue = String(cellValue).toLowerCase();
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return rawCellValue;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트 처리 (문자열만)
|
||||
const strValue = String(rawCellValue);
|
||||
const lowerValue = strValue.toLowerCase();
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||
|
||||
if (startIndex === -1) return cellValue;
|
||||
if (startIndex === -1) return rawCellValue;
|
||||
|
||||
const before = String(cellValue).slice(0, startIndex);
|
||||
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||
const before = strValue.slice(0, startIndex);
|
||||
const match = strValue.slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = strValue.slice(startIndex + searchTerm.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -307,7 +316,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
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}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
@@ -322,9 +333,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
// 이미지 셀은 overflow 허용
|
||||
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
|
||||
@@ -12,6 +12,96 @@ import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 전역 레지스트리 타입 선언
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용)
|
||||
if (inputType === "image" && value) {
|
||||
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
||||
// 🔑 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>
|
||||
);
|
||||
return <TableCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
||||
@@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
<td
|
||||
key={column.columnName}
|
||||
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",
|
||||
@@ -6112,7 +6178,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
data-row={index}
|
||||
data-col={colIndex}
|
||||
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",
|
||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||
// 🆕 포커스된 셀 스타일
|
||||
|
||||
Reference in New Issue
Block a user