Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add artifacts UI for shared links #3940

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
48 changes: 43 additions & 5 deletions client/src/components/Share/ShareView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { useParams } from 'react-router-dom';
import { useGetSharedMessages, useGetStartupConfig } from 'librechat-data-provider/react-query';
import SharedArtifactButton from './SharedArtifactButton';
import { useLocalize, useDocumentTitle } from '~/hooks';
import SharedArtifacts from './SharedArtifacts';
import { ShareContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import MessagesView from './MessagesView';
import { buildTree } from '~/utils';
import Footer from '../Chat/Footer';
import { cn } from '~/utils';
import store from '~/store';

function SharedView() {
const localize = useLocalize();
Expand All @@ -15,6 +20,8 @@ function SharedView() {
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : dataTree ?? null;
const [isArtifactPanelOpen, setIsArtifactPanelOpen] = useRecoilState(store.artifactsVisible);
const [codeArtifacts, setCodeArtifacts] = useRecoilState<boolean>(store.codeArtifacts);

// configure document title
let docTitle = '';
Expand All @@ -26,6 +33,23 @@ function SharedView() {

useDocumentTitle(docTitle);

useEffect(() => {
// Ensure artifact panel is initially closed
setIsArtifactPanelOpen(false);

// Store the initial codeArtifacts value
const initialCodeArtifacts = codeArtifacts;

// Set codeArtifacts to true for shared link page
setCodeArtifacts(true);

// Reset artifact panel state and codeArtifacts when component unmounts
return () => {
setIsArtifactPanelOpen(false);
setCodeArtifacts(initialCodeArtifacts);
};
}, [setIsArtifactPanelOpen, codeArtifacts, setCodeArtifacts]);

let content: JSX.Element;
if (isLoading) {
content = (
Expand Down Expand Up @@ -61,17 +85,31 @@ function SharedView() {
return (
<ShareContext.Provider value={{ isSharedConvo: true }}>
<main
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary"
style={{ paddingBottom: '50px' }}
className={cn(
'relative flex w-full grow overflow-hidden dark:bg-surface-secondary transition-all duration-300 ease-in-out',
isArtifactPanelOpen ? 'pr-[calc(50%-0.5rem)]' : ''
)}
>
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
<div
className={cn(
'transition-all duration-300 ease-in-out',
'relative flex h-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary',
isArtifactPanelOpen ? 'w-1/2 ml-2' : 'w-full'
)}
>
<div className="flex h-full flex-col text-text-primary" role="presentation">
{content}
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<div className={cn(
'w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent',
'transition-opacity duration-300 ease-in-out',
isArtifactPanelOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}>
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
</div>
</div>
</div>
<SharedArtifactButton onClick={() => setIsArtifactPanelOpen(true)} isOpen={isArtifactPanelOpen} />
<SharedArtifacts isOpen={isArtifactPanelOpen} onClose={() => setIsArtifactPanelOpen(false)} />
</main>
</ShareContext.Provider>
);
Expand Down
41 changes: 41 additions & 0 deletions client/src/components/Share/SharedArtifactButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';

interface SharedArtifactButtonProps {
onClick: () => void;
isOpen: boolean;
}

const SharedArtifactButton: React.FC<SharedArtifactButtonProps> = ({ onClick, isOpen }) => {
const localize = useLocalize();

return (
<button
type="button"
onClick={onClick}
className={cn(
"fixed right-4 top-4 z-50 rounded-md bg-surface-primary p-2 text-text-primary shadow-lg hover:bg-surface-secondary",
isOpen && "bg-surface-secondary"
)}
aria-label={localize('com_ui_toggle_artifacts')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 w-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
</button>
);
};

export default SharedArtifactButton;
182 changes: 182 additions & 0 deletions client/src/components/Share/SharedArtifacts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React, { useRef, useState, useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { RefreshCw } from 'lucide-react';
import * as Tabs from '@radix-ui/react-tabs';
import { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import { artifactsState } from '~/store/artifacts';
import { CodeMarkdown, CopyCodeButton } from '../Artifacts/Code';
import { getFileExtension } from '~/utils/artifacts';
import { ArtifactPreview } from '../Artifacts/ArtifactPreview';
import { cn } from '~/utils';
import store from '~/store';

interface SharedArtifactsProps {
isOpen: boolean;
onClose: () => void;
}

const SharedArtifacts: React.FC<SharedArtifactsProps> = ({ isOpen, onClose }) => {
const artifacts = useRecoilValue(artifactsState);
const currentArtifactId = useRecoilValue(store.currentArtifactId);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const previewRef = useRef<SandpackPreviewRef>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState('preview');

const orderedArtifactIds = artifacts ? Object.keys(artifacts) : [];
const currentArtifact = artifacts && currentArtifactId ? artifacts[currentArtifactId] : null;
const currentIndex = orderedArtifactIds.indexOf(currentArtifactId ?? '');

useEffect(() => {
if (isOpen && orderedArtifactIds.length > 0 && !currentArtifactId) {
// If no artifact is selected, select the first one
setCurrentArtifactId(orderedArtifactIds[0]);
}
}, [isOpen, orderedArtifactIds, currentArtifactId, setCurrentArtifactId]);

if (!artifacts || Object.keys(artifacts).length === 0) {
return null;
}

const handleRefresh = () => {
setIsRefreshing(true);
const client = previewRef.current?.getClient();
if (client != null) {
client.dispatch({ type: 'refresh' });
}
setTimeout(() => setIsRefreshing(false), 750);
};

const cycleArtifact = (direction: 'prev' | 'next') => {
let newIndex;
if (direction === 'prev') {
newIndex = currentIndex > 0 ? currentIndex - 1 : orderedArtifactIds.length - 1;
} else {
newIndex = currentIndex < orderedArtifactIds.length - 1 ? currentIndex + 1 : 0;
}
setCurrentArtifactId(orderedArtifactIds[newIndex]);
};

const isMermaid = currentArtifact?.type === 'mermaid';

return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
<div
className={cn(
'fixed right-0 top-0 z-50 h-[calc(100%-1rem)] w-[calc(50%-0.5rem)] overflow-hidden bg-surface-secondary shadow-lg transition-all duration-300 ease-in-out',
'mt-2 mr-2 mb-2 rounded-lg',
isOpen ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0 pointer-events-none'
)}
>
<div className="flex h-full flex-col rounded-lg">
{/* Header */}
<div className="flex items-center justify-between rounded-t-lg border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<h3 className="truncate text-sm text-text-primary">{currentArtifact?.title}</h3>
</div>
<div className="flex items-center">
{activeTab === 'preview' && (
<button
className={`mr-2 text-text-secondary transition-transform duration-500 ease-in-out ${
isRefreshing ? 'rotate-180' : ''
}`}
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Refresh"
>
<RefreshCw
size={16}
className={`transform ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
)}
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
<Tabs.Trigger
value="preview"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Preview
</Tabs.Trigger>
<Tabs.Trigger
value="code"
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
>
Code
</Tabs.Trigger>
</Tabs.List>
<button className="text-text-secondary" onClick={onClose}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
</button>
</div>
</div>
{/* Content */}
<Tabs.Content
value="code"
className={cn('flex-grow overflow-x-auto overflow-y-scroll bg-gray-900 p-4')}
>
{currentArtifact && (
<CodeMarkdown
content={`\`\`\`${getFileExtension(currentArtifact.type)}\n${
currentArtifact.content ?? ''
}\`\`\``}
isSubmitting={false}
/>
)}
</Tabs.Content>
<Tabs.Content
value="preview"
className={cn('flex-grow overflow-auto', isMermaid ? 'bg-[#282C34]' : 'bg-white')}
>
{currentArtifact && (
<ArtifactPreview
artifact={currentArtifact}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
/>
)}
</Tabs.Content>
{/* Footer */}
<div className="flex items-center justify-between rounded-b-lg border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
</svg>
</button>
<span className="text-xs">{`${currentIndex + 1} / ${orderedArtifactIds.length}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
</svg>
</button>
</div>
<div className="flex items-center">
{currentArtifact && <CopyCodeButton content={currentArtifact.content ?? ''} />}
</div>
</div>
</div>
</div>
</Tabs.Root>
);
};

export default SharedArtifacts;