🔗 feat: Enhance Share Functionality, Optimize DataTable & Fix Critical Bugs (#5220)

* 🔄 refactor: frontend and backend share link logic; feat: qrcode for share link; feat: refresh link

* 🐛 fix: Conditionally render shared link and refactor share link creation logic

* 🐛 fix: Correct conditional check for shareId in ShareButton component

* 🔄 refactor: Update shared links API and data handling; improve query parameters and response structure

* 🔄 refactor: Update shared links pagination and response structure; replace pageNumber with cursor for improved data fetching

* 🔄 refactor: DataTable performance optimization

* fix: delete shared link cache update

* 🔄 refactor: Enhance shared links functionality; add conversationId to shared link model and update related components

* 🔄 refactor: Add delete functionality to SharedLinkButton; integrate delete mutation and confirmation dialog

* 🔄 feat: Add AnimatedSearchInput component with gradient animations and search functionality; update search handling in API and localization

* 🔄 refactor: Improve SharedLinks component; enhance delete functionality and loading states, optimize AnimatedSearchInput, and refine DataTable scrolling behavior

* fix: mutation type issues with deleted shared link mutation

* fix: MutationOptions types

* fix: Ensure only public shared links are retrieved in getSharedLink function

* fix: `qrcode.react` install location

* fix: ensure non-public shared links are not fetched when checking for existing shared links, and remove deprecated .exec() method for queries

* fix: types and import order

* refactor: cleanup share button UI logic, make more intuitive

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-01-21 15:31:05 +01:00 committed by GitHub
parent 460cde0c0b
commit fa9e778399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 1779 additions and 1975 deletions

View file

@ -141,7 +141,6 @@ function ConvoOptions({
/>
{showShareDialog && (
<ShareButton
title={title ?? ''}
conversationId={conversationId ?? ''}
open={showShareDialog}
onOpenChange={setShowShareDialog}

View file

@ -1,112 +1,102 @@
import React, { useState, useEffect } from 'react';
import { OGDialog } from '~/components/ui';
import { useToastContext } from '~/Providers';
import type { TSharedLink } from 'librechat-data-provider';
import { useCreateSharedLinkMutation } from '~/data-provider';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { Button, Spinner, OGDialog } from '~/components';
import SharedLinkButton from './SharedLinkButton';
import { NotificationSeverity } from '~/common';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ShareButton({
conversationId,
title,
open,
onOpenChange,
triggerRef,
children,
}: {
conversationId: string;
title: string;
open: boolean;
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
triggerRef?: React.RefObject<HTMLButtonElement>;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mutate, isLoading } = useCreateSharedLinkMutation();
const [share, setShare] = useState<TSharedLink | null>(null);
const [isUpdated, setIsUpdated] = useState(false);
const [isNewSharedLink, setIsNewSharedLink] = useState(false);
const [showQR, setShowQR] = useState(false);
const [sharedLink, setSharedLink] = useState('');
const [isCopying, setIsCopying] = useState(false);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const copyLink = useCopyToClipboard({ text: sharedLink });
useEffect(() => {
if (!open && triggerRef && triggerRef.current) {
triggerRef.current.focus();
if (share?.shareId !== undefined) {
const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
setSharedLink(link);
}
}, [open, triggerRef]);
}, [share]);
useEffect(() => {
if (isLoading || share) {
return;
}
const data = {
conversationId,
title,
isAnonymous: true,
};
const button =
isLoading === true ? null : (
<SharedLinkButton
share={share}
conversationId={conversationId}
setShareDialogOpen={onOpenChange}
showQR={showQR}
setShowQR={setShowQR}
setSharedLink={setSharedLink}
/>
);
mutate(data, {
onSuccess: (result) => {
setShare(result);
setIsNewSharedLink(!result.isPublic);
},
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
// mutation.mutate should only be called once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!conversationId) {
return null;
}
const buttons = share && (
<SharedLinkButton
share={share}
conversationId={conversationId}
setShare={setShare}
isUpdated={isUpdated}
setIsUpdated={setIsUpdated}
/>
);
const shareId = share?.shareId ?? '';
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
buttons={buttons}
buttons={button}
showCloseButton={true}
showCancelButton={false}
title={localize('com_ui_share_link_to_chat')}
className="max-w-[550px]"
main={
<div>
<div className="h-full py-2 text-gray-400 dark:text-gray-200">
<div className="h-full py-2 text-text-primary">
{(() => {
if (isLoading) {
if (isLoading === true) {
return <Spinner className="m-auto h-14 animate-spin" />;
}
if (isUpdated) {
return isNewSharedLink
? localize('com_ui_share_created_message')
: localize('com_ui_share_updated_message');
}
return share?.isPublic === true
return share?.success === true
? localize('com_ui_share_update_message')
: localize('com_ui_share_create_message');
})()}
</div>
<div className="relative items-center rounded-lg p-2">
{showQR && (
<div className="mb-4 flex flex-col items-center">
<QRCodeSVG value={sharedLink} size={200} marginSize={2} className="rounded-2xl" />
</div>
)}
{shareId && (
<div className="flex items-center gap-2 rounded-md bg-surface-secondary p-2">
<div className="flex-1 break-all text-sm text-text-secondary">{sharedLink}</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (isCopying) {
return;
}
copyLink(setIsCopying);
}}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
)}
</div>
</div>
}
/>

View file

@ -1,31 +1,38 @@
import { useState } from 'react';
import copy from 'copy-to-clipboard';
import { Copy, Link } from 'lucide-react';
import type { TSharedLink } from 'librechat-data-provider';
import { useUpdateSharedLinkMutation } from '~/data-provider';
import { useState, useCallback } from 'react';
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
import {
useCreateSharedLinkMutation,
useUpdateSharedLinkMutation,
useDeleteSharedLinkMutation,
} from '~/data-provider';
import { Button, OGDialog, Spinner, TooltipAnchor, Label } from '~/components';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
conversationId,
share,
setShare,
isUpdated,
setIsUpdated,
conversationId,
setShareDialogOpen,
showQR,
setShowQR,
setSharedLink,
}: {
share: TSharedLinkGetResponse | undefined;
conversationId: string;
share: TSharedLink;
setShare: (share: TSharedLink) => void;
isUpdated: boolean;
setIsUpdated: (isUpdated: boolean) => void;
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
showQR: boolean;
setShowQR: (showQR: boolean) => void;
setSharedLink: (sharedLink: string) => void;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isCopying, setIsCopying] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const shareId = share?.shareId ?? '';
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation({
const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
@ -35,92 +42,145 @@ export default function SharedLinkButton({
},
});
const copyLink = () => {
if (!share) {
return;
}
setIsCopying(true);
const sharedLink =
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId;
copy(sharedLink);
setTimeout(() => {
setIsCopying(false);
}, 1500);
};
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const deleteMutation = useDeleteSharedLinkMutation({
onSuccess: async () => {
setShowDeleteDialog(false);
setShareDialogOpen(false);
},
onError: (error) => {
console.error('Delete error:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const generateShareLink = useCallback((shareId: string) => {
return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
}, []);
const updateSharedLink = async () => {
if (!share) {
if (!shareId) {
return;
}
const result = await mutateAsync({
shareId: share.shareId,
conversationId: conversationId,
isPublic: true,
isVisible: true,
isAnonymous: true,
});
if (result) {
setShare(result);
setIsUpdated(true);
copyLink();
}
};
const getHandler = () => {
if (isUpdated) {
return {
handler: () => {
copyLink();
},
label: (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copy_link')}
</>
),
};
}
if (share.isPublic) {
return {
handler: async () => {
await updateSharedLink();
},
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_update_link')}
</>
),
};
}
return {
handler: updateSharedLink,
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_create_link')}
</>
),
};
const updateShare = await mutateAsync({ shareId });
const newLink = generateShareLink(updateShare.shareId);
setSharedLink(newLink);
};
const createShareLink = async () => {
const share = await mutate({ conversationId });
const newLink = generateShareLink(share.shareId);
setSharedLink(newLink);
};
const handleDelete = async () => {
if (!shareId) {
return;
}
try {
await deleteMutation.mutateAsync({ shareId });
showToast({
message: localize('com_ui_shared_link_delete_success'),
severity: NotificationSeverity.SUCCESS,
});
} catch (error) {
console.error('Failed to delete shared link:', error);
showToast({
message: localize('com_ui_share_delete_error'),
severity: NotificationSeverity.ERROR,
});
}
};
const handlers = getHandler();
return (
<button
disabled={isLoading || isCopying}
onClick={() => {
handlers.handler();
}}
className="btn btn-primary flex items-center justify-center"
>
{isCopying && (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copied')}
</>
)}
{!isCopying && !isLoading && handlers.label}
{!isCopying && isLoading && <Spinner className="h-4 w-4" />}
</button>
<>
<div className="flex gap-2">
{!shareId && (
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
{!isCreateLoading && localize('com_ui_create_link')}
{isCreateLoading && <Spinner className="size-4" />}
</Button>
)}
{shareId && (
<div className="flex items-center gap-2">
<TooltipAnchor
description={localize('com_ui_refresh_link')}
render={(props) => (
<Button
{...props}
onClick={() => updateSharedLink()}
variant="outline"
disabled={isUpdateLoading}
>
{isUpdateLoading ? (
<Spinner className="size-4" />
) : (
<RotateCw className="size-4" />
)}
</Button>
)}
/>
<TooltipAnchor
description={showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr')}
render={(props) => (
<Button {...props} onClick={() => setShowQR(!showQR)} variant="outline">
<QrCode className="size-4" />
</Button>
)}
/>
<TooltipAnchor
description={localize('com_ui_delete')}
render={(props) => (
<Button {...props} onClick={() => setShowDeleteDialog(true)} variant="destructive">
<Trash2 className="size-4" />
</Button>
)}
/>
</div>
)}
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link')}
className="max-w-[450px]"
main={
<>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label
htmlFor="dialog-confirm-delete"
className="text-left text-sm font-medium"
>
{localize('com_ui_delete_confirm')} <strong>&quot;{shareId}&quot;</strong>
</Label>
</div>
</div>
</>
}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
</>
);
}