import { useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconChevronLeft, IconChevronRight, IconDownload, IconInfoCircle, IconX, } from "@tabler/icons-react"; import { useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { assetPreviewSources } from "@/lib/asset-preview-sources"; import { assetMediaUrl } from "@/lib/asset-urls"; export type PreviewAsset = { id: string; libraryId?: string | null; role?: string | null; status?: string | null; title?: string | null; description?: string | null; altText?: string | null; prompt?: string | null; mediaType?: string | null; mimeType?: string | null; width?: number | null; height?: number | null; url?: string | null; previewUrl?: string | null; thumbnailUrl?: string | null; downloadUrl?: string | null; folderId?: string | null; category?: string | null; model?: string | null; aspectRatio?: string | null; durationSeconds?: number | null; metadata?: Record | null; lineage?: { label?: string | null } | null; }; /** * The single side-panel asset preview used everywhere an asset is opened: * large media on the left, details on the right, a top toolbar (download, * details toggle, close), and previous/next navigation across `assets`. */ export function AssetPreviewDialog({ asset, assets, onAssetChange, renderImage, }: { asset: PreviewAsset | null; assets: PreviewAsset[]; onAssetChange: (asset: PreviewAsset | null) => void; /** Optional media renderer (e.g. an embed/COEP-aware image loader). */ renderImage?: (asset: PreviewAsset) => ReactNode; }) { const t = useT(); const exportAsset = useActionMutation("export-asset"); const [showDetails, setShowDetails] = useState(true); return ( { if (!open) onAssetChange(null); }} > {asset && (() => { const previewIndex = assets.findIndex( (candidate) => candidate.id === asset.id, ); const hasPrev = previewIndex > 0; const hasNext = previewIndex >= 0 && previewIndex < assets.length - 1; const showPreviousAsset = () => { if (hasPrev) onAssetChange(assets[previewIndex - 1]); }; const showNextAsset = () => { if (hasNext) onAssetChange(assets[previewIndex + 1]); }; const isVideo = asset.mediaType === "video" || Boolean(asset.mimeType?.startsWith("video/")); const videoSrc = assetPreviewSources(asset)[0]; const downloadAsset = () => { // Synthetic starter-preset assets aren't database rows, so // export-asset can't resolve them; download the source directly. if (isStarterPreviewAsset(asset)) { const directUrl = assetPreviewSources(asset)[0]; if (directUrl) window.location.href = directUrl; else toast.error(t("assetDetail.downloadFailed")); return; } exportAsset.mutate( { assetId: asset.id }, { onSuccess: (result: any) => { const url = assetMediaUrl(result?.downloadUrl) ?? result?.downloadUrl; if (url) window.location.href = url; else toast.error(t("assetDetail.downloadFailed")); }, onError: () => toast.error(t("assetDetail.downloadFailed")), }, ); }; return ( { if (event.key === "ArrowLeft") showPreviousAsset(); if (event.key === "ArrowRight") showNextAsset(); }} className="assets-preview-dialog flex max-h-[92vh] w-[94vw] max-w-6xl flex-col gap-0 overflow-hidden p-0" > {assetPreviewTitle(asset)} {t("library.fullSizePreview", { title: assetPreviewTitle(asset), })}
{isVideo ? (
{showDetails && ( )}
{(hasPrev || hasNext) && (
)}
); })()}
); } function AssetPreviewImage({ asset }: { asset: PreviewAsset }) { const t = useT(); const sources = assetPreviewSources(asset); const [sourceIndex, setSourceIndex] = useState(0); const [unavailable, setUnavailable] = useState(false); const sourcesKey = sources.join("\n"); useEffect(() => { setSourceIndex(0); setUnavailable(false); }, [sourcesKey]); const src = sources[sourceIndex]; if (!src || unavailable) { return (
{t("assetDetail.previewUnavailable")}
); } return ( {asset.altText { const nextIndex = sourceIndex + 1; if (nextIndex < sources.length) setSourceIndex(nextIndex); else setUnavailable(true); }} /> ); } function AssetPreviewDetails({ asset, isVideo, }: { asset: PreviewAsset; isVideo: boolean; }) { const t = useT(); const category = assetPreviewCategoryLabel(asset, t); return (

{assetPreviewTitle(asset)}

{asset.status ? ( {asset.status} ) : null} {asset.role ? {asset.role} : null} {isVideo ? "video" : "image"} {category ? {category} : null}
{isVideo ? ( ) : ( )}
); } function PreviewField({ label, value, multiline, }: { label: string; value: ReactNode; multiline?: boolean; }) { return (
{label}
{value}
); } function assetPreviewTitle(asset: PreviewAsset): string { return ( asset.lineage?.label || asset.title || asset.prompt || "Untitled asset" ); } function assetPreviewCategoryLabel( asset: PreviewAsset, t: (key: string, options?: Record) => string, ): string | null { const metadata = (asset.metadata ?? {}) as Record; if (metadata.intent === "subject" || asset.role === "subject_reference") { return t("assetDetail.contentOnly"); } const category = (metadata.category as string | undefined) ?? asset.category; if (typeof category !== "string") return null; if (category === "style-only") return t("assetDetail.styleReference"); if (category === "skeleton") return t("assetDetail.skeletonPlate"); return category.replace(/-/g, " "); } function formatPreviewDimensions( width?: number | null, height?: number | null, ) { const dimensions = `${width || "?"} x ${height || "?"}`; if (!width || !height) return dimensions; const divisor = previewGcd(width, height); return ( {dimensions} {`${width / divisor}:${height / divisor}`} ); } function previewGcd(a: number, b: number): number { return b === 0 ? a : previewGcd(b, a % b); } function isStarterPreviewAsset(asset: PreviewAsset): boolean { return ( asset.id.startsWith("starter-") || Boolean(asset.libraryId?.startsWith("starter:")) ); }