import { useRef, useState, useEffect } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import { Code, Play, RefreshCw, X } from 'lucide-react'; import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; import { useShareContext, useMutationState } from '~/Providers'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; import DownloadArtifact from './DownloadArtifact'; import ArtifactVersion from './ArtifactVersion'; import ArtifactTabs from './ArtifactTabs'; import { CopyCodeButton } from './Code'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; const MAX_BLUR_AMOUNT = 32; const MAX_BACKDROP_OPACITY = 0.3; export default function Artifacts() { const localize = useLocalize(); const { isMutating } = useMutationState(); const { isSharedConvo } = useShareContext(); const isMobile = useMediaQuery('(max-width: 868px)'); const editorRef = useRef(); const previewRef = useRef(); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [isMounted, setIsMounted] = useState(false); const [height, setHeight] = useState(90); const [isDragging, setIsDragging] = useState(false); const [blurAmount, setBlurAmount] = useState(0); const dragStartY = useRef(0); const dragStartHeight = useRef(90); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); const tabOptions = [ { value: 'code', label: localize('com_ui_code'), icon: , }, { value: 'preview', label: localize('com_ui_preview'), icon: , }, ]; useEffect(() => { setIsMounted(true); const delay = isMobile ? 50 : 30; const timer = setTimeout(() => setIsVisible(true), delay); return () => { clearTimeout(timer); setIsMounted(false); }; }, [isMobile]); useEffect(() => { if (!isMobile) { setBlurAmount(0); return; } const minHeightForBlur = 50; const maxHeightForBlur = 100; if (height <= minHeightForBlur) { setBlurAmount(0); } else if (height >= maxHeightForBlur) { setBlurAmount(MAX_BLUR_AMOUNT); } else { const progress = (height - minHeightForBlur) / (maxHeightForBlur - minHeightForBlur); setBlurAmount(Math.round(progress * MAX_BLUR_AMOUNT)); } }, [height, isMobile]); const { activeTab, setActiveTab, currentIndex, currentArtifact, orderedArtifactIds, setCurrentArtifactId, } = useArtifacts(); const handleDragStart = (e: React.PointerEvent) => { setIsDragging(true); dragStartY.current = e.clientY; dragStartHeight.current = height; (e.target as HTMLElement).setPointerCapture(e.pointerId); }; const handleDragMove = (e: React.PointerEvent) => { if (!isDragging) { return; } const deltaY = dragStartY.current - e.clientY; const viewportHeight = window.innerHeight; const deltaPercentage = (deltaY / viewportHeight) * 100; const newHeight = Math.max(10, Math.min(100, dragStartHeight.current + deltaPercentage)); setHeight(newHeight); }; const handleDragEnd = (e: React.PointerEvent) => { if (!isDragging) { return; } setIsDragging(false); (e.target as HTMLElement).releasePointerCapture(e.pointerId); // Snap to positions based on final height if (height < 30) { closeArtifacts(); } else if (height > 95) { setHeight(100); } else if (height < 60) { setHeight(50); } else { setHeight(90); } }; if (!currentArtifact || !isMounted) { return null; } const handleRefresh = () => { setIsRefreshing(true); const client = previewRef.current?.getClient(); if (client) { client.dispatch({ type: 'refresh' }); } setTimeout(() => setIsRefreshing(false), 750); }; const closeArtifacts = () => { if (isMobile) { setIsClosing(true); setIsVisible(false); setTimeout(() => { setArtifactsVisible(false); setIsClosing(false); setHeight(90); }, 250); } else { resetCurrentArtifactId(); setArtifactsVisible(false); } }; const backdropOpacity = blurAmount > 0 ? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY : 0; return (
{/* Mobile backdrop with dynamic blur */} {isMobile && (
= 8 ? closeArtifacts : undefined} aria-hidden="true" /> )}
{isMobile && (
)} {/* Header */}
{!isMobile && (
)}
{activeTab === 'preview' && ( )} {activeTab !== 'preview' && isMutating && ( )} {orderedArtifactIds.length > 1 && ( { const target = orderedArtifactIds[index]; if (target) { setCurrentArtifactId(target); } }} /> )}
} previewRef={previewRef as React.MutableRefObject} isSharedConvo={isSharedConvo} />
{isMobile && (
)}
); }