mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-27 13:48:51 +01:00
✨ feat: Refactor Artifacts component structure and add mobile/desktop variants for improved UI
This commit is contained in:
parent
6fb76c7d60
commit
3c02a7b2e8
6 changed files with 479 additions and 277 deletions
|
|
@ -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<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
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<CodeEditorRef>,
|
||||
previewRef: previewRef as React.MutableRefObject<SandpackPreviewRef>,
|
||||
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 (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Mobile backdrop with dynamic blur */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
|
||||
isVisible && !isClosing
|
||||
? 'transition-all duration-300'
|
||||
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
|
||||
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
|
||||
)}
|
||||
style={{
|
||||
opacity: isVisible && !isClosing ? backdropOpacity : 0,
|
||||
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
}}
|
||||
onClick={blurAmount >= 8 ? closeArtifacts : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col bg-surface-primary text-xl text-text-primary',
|
||||
isMobile
|
||||
? cn(
|
||||
'fixed inset-x-0 bottom-0 z-[100] rounded-t-[20px] shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
|
||||
isVisible && !isClosing
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'duration-250 translate-y-full opacity-0 transition-all',
|
||||
isDragging ? '' : 'transition-all duration-300',
|
||||
)
|
||||
: cn(
|
||||
'h-full shadow-2xl',
|
||||
isVisible && !isClosing
|
||||
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||
),
|
||||
)}
|
||||
style={isMobile ? { height: `${height}vh` } : { overflow: 'hidden' }}
|
||||
>
|
||||
{isMobile && (
|
||||
<div
|
||||
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
|
||||
isMobile ? 'justify-center' : 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center transition-all duration-500',
|
||||
isVisible && !isClosing
|
||||
? 'translate-x-0 opacity-100'
|
||||
: '-translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
<Radio
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-all duration-500',
|
||||
isMobile ? 'min-w-max' : '',
|
||||
isVisible && !isClosing ? 'translate-x-0 opacity-100' : 'translate-x-2 opacity-0',
|
||||
)}
|
||||
>
|
||||
{activeTab === 'preview' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={localize('com_ui_refresh')}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="animate-spin text-text-secondary" />
|
||||
)}
|
||||
{orderedArtifactIds.length > 1 && (
|
||||
<ArtifactVersion
|
||||
currentIndex={currentIndex}
|
||||
totalVersions={orderedArtifactIds.length}
|
||||
onVersionChange={(index) => {
|
||||
const target = orderedArtifactIds[index];
|
||||
if (target) {
|
||||
setCurrentArtifactId(target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={closeArtifacts}
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!isRefreshing}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isRefreshing ? 'scale-100' : 'scale-95',
|
||||
)}
|
||||
>
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
|
||||
<Radio
|
||||
fullWidth
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
return isMobile ? <MobileArtifacts {...sharedProps} /> : <DesktopArtifacts {...sharedProps} />;
|
||||
}
|
||||
|
|
|
|||
104
client/src/components/Artifacts/ArtifactsHeader.tsx
Normal file
104
client/src/components/Artifacts/ArtifactsHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center justify-between gap-2 border-b border-border-light bg-surface-primary-alt px-3 py-2 transition-all duration-300',
|
||||
isMobile ? 'justify-center' : 'w-full overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="flex items-center transition-all duration-500">
|
||||
<Radio
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 transition-all duration-500',
|
||||
isMobile ? 'min-w-max' : '',
|
||||
)}
|
||||
>
|
||||
{activeTab === 'preview' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={localize('com_ui_refresh')}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} className="transition-transform duration-200" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="animate-spin text-text-secondary" />
|
||||
)}
|
||||
{orderedArtifactIds.length > 1 && (
|
||||
<ArtifactVersion
|
||||
currentIndex={currentIndex}
|
||||
totalVersions={orderedArtifactIds.length}
|
||||
onVersionChange={(index) => {
|
||||
const target = orderedArtifactIds[index];
|
||||
if (target) {
|
||||
setCurrentArtifactId(target);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label={localize('com_ui_close')}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
client/src/components/Artifacts/ArtifactsTypes.ts
Normal file
23
client/src/components/Artifacts/ArtifactsTypes.ts
Normal file
|
|
@ -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<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
isMutating: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}
|
||||
|
||||
export interface TabOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
106
client/src/components/Artifacts/DesktopArtifacts.tsx
Normal file
106
client/src/components/Artifacts/DesktopArtifacts.tsx
Normal file
|
|
@ -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 (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col bg-surface-primary text-xl text-text-primary shadow-2xl',
|
||||
isVisible && !isClosing
|
||||
? 'duration-350 translate-x-0 opacity-100 transition-all'
|
||||
: 'translate-x-5 opacity-0 transition-all duration-300',
|
||||
)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={cn('flex items-center transition-all duration-500')}>
|
||||
<ArtifactsHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
currentIndex={currentIndex}
|
||||
orderedArtifactIds={orderedArtifactIds}
|
||||
setCurrentArtifactId={setCurrentArtifactId}
|
||||
currentArtifact={currentArtifact}
|
||||
isMutating={isMutating}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onClose={handleClose}
|
||||
isMobile={false}
|
||||
tabOptions={tabOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef}
|
||||
previewRef={previewRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Refresh overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!isRefreshing}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isRefreshing ? 'scale-100' : 'scale-95',
|
||||
)}
|
||||
>
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
215
client/src/components/Artifacts/MobileArtifacts.tsx
Normal file
215
client/src/components/Artifacts/MobileArtifacts.tsx
Normal file
|
|
@ -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 (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* Mobile backdrop with dynamic blur */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[99] bg-black will-change-[opacity,backdrop-filter]',
|
||||
isVisible && !isClosing
|
||||
? 'transition-all duration-300'
|
||||
: 'pointer-events-none opacity-0 backdrop-blur-none transition-opacity duration-150',
|
||||
blurAmount < 8 && isVisible && !isClosing ? 'pointer-events-none' : '',
|
||||
)}
|
||||
style={{
|
||||
opacity: isVisible && !isClosing ? backdropOpacity : 0,
|
||||
backdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
WebkitBackdropFilter: isVisible && !isClosing ? `blur(${blurAmount}px)` : 'none',
|
||||
}}
|
||||
onClick={blurAmount >= 8 ? handleClose : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-[100] flex w-full flex-col rounded-t-[20px] bg-surface-primary text-xl text-text-primary shadow-[0_-10px_60px_rgba(0,0,0,0.35)]',
|
||||
isVisible && !isClosing
|
||||
? 'translate-y-0 opacity-100'
|
||||
: 'duration-250 translate-y-full opacity-0 transition-all',
|
||||
isDragging ? '' : 'transition-all duration-300',
|
||||
)}
|
||||
style={{ height: `${height}vh` }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex flex-shrink-0 cursor-grab items-center justify-center bg-surface-primary-alt pb-1.5 pt-2.5 active:cursor-grabbing"
|
||||
onPointerDown={handleDragStart}
|
||||
onPointerMove={handleDragMove}
|
||||
onPointerUp={handleDragEnd}
|
||||
onPointerCancel={handleDragEnd}
|
||||
>
|
||||
<div className="h-1 w-12 rounded-full bg-border-xheavy opacity-40 transition-all duration-200 active:opacity-60" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<ArtifactsHeader
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
currentIndex={currentIndex}
|
||||
orderedArtifactIds={orderedArtifactIds}
|
||||
setCurrentArtifactId={setCurrentArtifactId}
|
||||
currentArtifact={currentArtifact}
|
||||
isMutating={isMutating}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
onClose={handleClose}
|
||||
isMobile={true}
|
||||
tabOptions={tabOptions}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-primary">
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<ArtifactTabs
|
||||
artifact={currentArtifact}
|
||||
editorRef={editorRef}
|
||||
previewRef={previewRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Refresh overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300 ease-in-out',
|
||||
isRefreshing ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!isRefreshing}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isRefreshing ? 'scale-100' : 'scale-95',
|
||||
)}
|
||||
>
|
||||
<Spinner size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tabs */}
|
||||
<div className="flex-shrink-0 border-t border-border-light bg-surface-primary-alt p-2">
|
||||
<Radio
|
||||
fullWidth
|
||||
options={tabOptions}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
disabled={isMutating && activeTab !== 'code'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -140,14 +140,14 @@ const SidePanelGroup = memo(
|
|||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
onLayout={(sizes) => 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"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={currentLayout[0]}
|
||||
minSize={minSizeMain}
|
||||
order={1}
|
||||
id="messages-view"
|
||||
className="ease-[cubic-bezier(0.25,0.46,0.45,0.94)] transition-all duration-500"
|
||||
className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation"
|
||||
>
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
|
|
@ -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"
|
||||
>
|
||||
<div className="h-full min-w-[400px] overflow-hidden">{artifacts}</div>
|
||||
</ResizablePanel>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue