diff --git a/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx b/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx index 2676bed6..7623c1b5 100644 --- a/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx +++ b/frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx @@ -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[]; @@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC = ({ {imageValue && (
{ + 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 = ``; + parent.appendChild(fallback); + } }} />
diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index e127bdbb..36bec277 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -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; + formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record) => React.ReactNode; getColumnWidth: (column: ColumnConfig) => number; containerWidth?: string; // 컨테이너 너비 설정 loading?: boolean; @@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC = ({ 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 = ({ 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 = ({ 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 }), diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 6e529ab9..59cb47fa 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -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(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 ( +
+
+
+ ); + } + + if (error || !imgSrc) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ 이미지 { + 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)} + /> +
+ ); +}); +TableCellImage.displayName = "TableCellImage"; + // 🆕 RelatedDataButtons 전역 레지스트리 타입 선언 declare global { interface Window { @@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC = ({ // 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 ( -
- 이미지 { - e.stopPropagation(); - // 이미지 클릭 시 새 탭에서 크게 보기 - window.open(imageUrl, "_blank"); - }} - onError={(e) => { - // 이미지 로드 실패 시 기본 아이콘 표시 - (e.target as HTMLImageElement).style.display = "none"; - }} - /> -
- ); + return ; } // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 @@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC = ({ = ({ 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)]", // 🆕 포커스된 셀 스타일