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:
parent
5b44a41651
commit
9785f098d8
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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)]",
|
||||||
// 🆕 포커스된 셀 스타일
|
// 🆕 포커스된 셀 스타일
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue