diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index 310d6ed969..d679260529 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -1,17 +1,14 @@ import { useRef, useState, useEffect } from 'react'; -import * as Tabs from '@radix-ui/react-tabs'; -import { Code, Play, RefreshCw, X } from 'lucide-react'; +import { Code, Play } from 'lucide-react'; import { useSetRecoilState, useResetRecoilState } from 'recoil'; -import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; +import { useMediaQuery } from '@librechat/client'; import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; -import DownloadArtifact from './DownloadArtifact'; -import ArtifactVersion from './ArtifactVersion'; +import MobileArtifacts from './MobileArtifacts'; +import DesktopArtifacts from './DesktopArtifacts'; import { useEditorContext } from '~/Providers'; -import ArtifactTabs from './ArtifactTabs'; -import { CopyCodeButton } from './Code'; +import type { TabOption } from './ArtifactsTypes'; import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; import store from '~/store'; export default function Artifacts() { @@ -20,19 +17,12 @@ export default function Artifacts() { 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 [isRefreshing, setIsRefreshing] = useState(false); const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); - const tabOptions = [ + const tabOptions: TabOption[] = [ { value: 'code', label: localize('com_ui_code'), @@ -45,35 +35,6 @@ export default function Artifacts() { }, ]; - 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, @@ -83,45 +44,10 @@ export default function Artifacts() { 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) { - // Close if dragged down significantly - closeArtifacts(); - } else if (height > 95) { - // Snap to full height if dragged near top - setHeight(100); - } else if (height < 60) { - // Snap to minimum if in lower range - setHeight(50); - } else { - // Snap to default - setHeight(90); - } - }; + useEffect(() => { + setIsMounted(true); + return () => setIsMounted(false); + }, []); if (!currentArtifact || !isMounted) { return null; @@ -136,201 +62,30 @@ export default function Artifacts() { setTimeout(() => setIsRefreshing(false), 750); }; - const closeArtifacts = () => { + const handleClose = () => { if (isMobile) { - setIsClosing(true); - setIsVisible(false); - setTimeout(() => { - setArtifactsVisible(false); - setIsClosing(false); - setHeight(90); - }, 250); + setArtifactsVisible(false); } else { resetCurrentArtifactId(); setArtifactsVisible(false); } }; - // Matches the maximum blur applied when the sheet is fully expanded - const MAX_BLUR_AMOUNT = 32; - // Ensures the backdrop caps at 30% opacity when blur reaches its maximum - const MAX_BACKDROP_OPACITY = 0.3; + const sharedProps = { + currentArtifact, + activeTab, + setActiveTab, + currentIndex, + orderedArtifactIds, + setCurrentArtifactId, + editorRef: editorRef as React.MutableRefObject, + previewRef: previewRef as React.MutableRefObject, + isMutating, + onClose: handleClose, + onRefresh: handleRefresh, + isRefreshing, + tabOptions, + }; - 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} - /> -
- -
-
- -
-
-
- - {isMobile && ( -
- -
- )} -
-
- - ); + return isMobile ? : ; } diff --git a/client/src/components/Artifacts/ArtifactsHeader.tsx b/client/src/components/Artifacts/ArtifactsHeader.tsx new file mode 100644 index 0000000000..47e8f9fe16 --- /dev/null +++ b/client/src/components/Artifacts/ArtifactsHeader.tsx @@ -0,0 +1,104 @@ +import { RefreshCw, X } from 'lucide-react'; +import { Button, Spinner, Radio } from '@librechat/client'; +import type { TabOption } from './ArtifactsTypes'; +import DownloadArtifact from './DownloadArtifact'; +import ArtifactVersion from './ArtifactVersion'; +import { CopyCodeButton } from './Code'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; +import type { Artifact } from '~/common'; + +interface ArtifactsHeaderProps { + activeTab: string; + setActiveTab: (tab: string) => void; + currentIndex: number; + orderedArtifactIds: string[]; + setCurrentArtifactId: (id: string) => void; + currentArtifact: Artifact; + isMutating: boolean; + isRefreshing: boolean; + onRefresh: () => void; + onClose: () => void; + isMobile?: boolean; + tabOptions: TabOption[]; +} + +export default function ArtifactsHeader({ + activeTab, + setActiveTab, + currentIndex, + orderedArtifactIds, + setCurrentArtifactId, + currentArtifact, + isMutating, + isRefreshing, + onRefresh, + onClose, + isMobile = false, + tabOptions, +}: ArtifactsHeaderProps) { + const localize = useLocalize(); + + return ( +
+ {!isMobile && ( +
+ +
+ )} + +
+ {activeTab === 'preview' && ( + + )} + {activeTab !== 'preview' && isMutating && ( + + )} + {orderedArtifactIds.length > 1 && ( + { + const target = orderedArtifactIds[index]; + if (target) { + setCurrentArtifactId(target); + } + }} + /> + )} + + + +
+
+ ); +} diff --git a/client/src/components/Artifacts/ArtifactsTypes.ts b/client/src/components/Artifacts/ArtifactsTypes.ts new file mode 100644 index 0000000000..f9de159489 --- /dev/null +++ b/client/src/components/Artifacts/ArtifactsTypes.ts @@ -0,0 +1,23 @@ +import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; +import type { Artifact } from '~/common'; + +export interface ArtifactsSharedProps { + currentArtifact: Artifact; + activeTab: string; + setActiveTab: (tab: string) => void; + currentIndex: number; + orderedArtifactIds: string[]; + setCurrentArtifactId: (id: string) => void; + editorRef: React.MutableRefObject; + previewRef: React.MutableRefObject; + isMutating: boolean; + onClose: () => void; + onRefresh: () => void; + isRefreshing: boolean; +} + +export interface TabOption { + value: string; + label: string; + icon: React.ReactNode; +} diff --git a/client/src/components/Artifacts/DesktopArtifacts.tsx b/client/src/components/Artifacts/DesktopArtifacts.tsx new file mode 100644 index 0000000000..ece7856edd --- /dev/null +++ b/client/src/components/Artifacts/DesktopArtifacts.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { Spinner } from '@librechat/client'; +import type { ArtifactsSharedProps, TabOption } from './ArtifactsTypes'; +import ArtifactsHeader from './ArtifactsHeader'; +import ArtifactTabs from './ArtifactTabs'; +import { cn } from '~/utils'; + +interface DesktopArtifactsProps extends ArtifactsSharedProps { + tabOptions: TabOption[]; +} + +export default function DesktopArtifacts({ + currentArtifact, + activeTab, + setActiveTab, + currentIndex, + orderedArtifactIds, + setCurrentArtifactId, + editorRef, + previewRef, + isMutating, + onClose, + onRefresh, + isRefreshing, + tabOptions, +}: DesktopArtifactsProps) { + const [isVisible, setIsVisible] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 30); + return () => clearTimeout(timer); + }, []); + + const handleClose = () => { + setIsClosing(true); + setIsVisible(false); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 300); + }; + + return ( + +
+ {/* Header */} +
+ +
+ + {/* Content */} +
+
+ +
+ + {/* Refresh overlay */} +
+
+ +
+
+
+
+
+ ); +} diff --git a/client/src/components/Artifacts/MobileArtifacts.tsx b/client/src/components/Artifacts/MobileArtifacts.tsx new file mode 100644 index 0000000000..db4c744657 --- /dev/null +++ b/client/src/components/Artifacts/MobileArtifacts.tsx @@ -0,0 +1,215 @@ +import { useRef, useState, useEffect } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { Spinner, Radio } from '@librechat/client'; +import type { ArtifactsSharedProps, TabOption } from './ArtifactsTypes'; +import ArtifactsHeader from './ArtifactsHeader'; +import ArtifactTabs from './ArtifactTabs'; +import { cn } from '~/utils'; + +const MAX_BLUR_AMOUNT = 32; +const MAX_BACKDROP_OPACITY = 0.3; + +interface MobileArtifactsProps extends ArtifactsSharedProps { + tabOptions: TabOption[]; +} + +export default function MobileArtifacts({ + currentArtifact, + activeTab, + setActiveTab, + currentIndex, + orderedArtifactIds, + setCurrentArtifactId, + editorRef, + previewRef, + isMutating, + onClose, + onRefresh, + isRefreshing, + tabOptions, +}: MobileArtifactsProps) { + const [isVisible, setIsVisible] = useState(false); + const [isClosing, setIsClosing] = 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); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timer); + }, []); + + useEffect(() => { + 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]); + + 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) { + handleClose(); + } else if (height > 95) { + setHeight(100); + } else if (height < 60) { + setHeight(50); + } else { + setHeight(90); + } + }; + + const handleClose = () => { + setIsClosing(true); + setIsVisible(false); + setTimeout(() => { + onClose(); + setIsClosing(false); + setHeight(90); + }, 250); + }; + + const backdropOpacity = + blurAmount > 0 + ? (Math.min(blurAmount, MAX_BLUR_AMOUNT) / MAX_BLUR_AMOUNT) * MAX_BACKDROP_OPACITY + : 0; + + return ( + +
+ {/* Mobile backdrop with dynamic blur */} +
= 8 ? handleClose : undefined} + aria-hidden="true" + /> + +
+ {/* Drag handle */} +
+
+
+ + {/* Header */} + + + {/* Content */} +
+
+ +
+ + {/* Refresh overlay */} +
+
+ +
+
+
+ + {/* Bottom tabs */} +
+ +
+
+
+ + ); +} diff --git a/client/src/components/SidePanel/SidePanelGroup.tsx b/client/src/components/SidePanel/SidePanelGroup.tsx index 0bdf5e286c..e279a27493 100644 --- a/client/src/components/SidePanel/SidePanelGroup.tsx +++ b/client/src/components/SidePanel/SidePanelGroup.tsx @@ -140,14 +140,14 @@ const SidePanelGroup = memo( throttledSaveLayout(sizes)} - className="ease-[cubic-bezier(0.25,0.46,0.45,0.94)] relative h-full w-full flex-1 overflow-auto bg-presentation transition-all duration-500" + className="relative h-full w-full flex-1 overflow-auto bg-presentation" > {children} @@ -167,7 +167,6 @@ const SidePanelGroup = memo( collapsedSize={0} order={2} id="artifacts-panel" - className="ease-[cubic-bezier(0.25,0.46,0.45,0.94)] transition-all duration-500" >
{artifacts}