mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-29 22:58:51 +01:00
🔗 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:
parent
460cde0c0b
commit
fa9e778399
55 changed files with 1779 additions and 1975 deletions
|
|
@ -141,7 +141,6 @@ function ConvoOptions({
|
|||
/>
|
||||
{showShareDialog && (
|
||||
<ShareButton
|
||||
title={title ?? ''}
|
||||
conversationId={conversationId ?? ''}
|
||||
open={showShareDialog}
|
||||
onOpenChange={setShowShareDialog}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>"{shareId}"</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
|
||||
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TooltipAnchor,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
} from '~/components';
|
||||
|
||||
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const localize = useLocalize();
|
||||
|
||||
const { showToast } = useToastContext();
|
||||
const mutation = useDeleteSharedLinkMutation({
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
setIsDeleting(false);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
|
||||
if (mutation.isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
await mutation.mutateAsync({ shareId });
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/share/${sharedLink.shareId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center text-blue-500 hover:underline"
|
||||
>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{sharedLink.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sharedLink.conversationId && (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
aria-label="Delete shared link"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</OGDialogTrigger>
|
||||
<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>{sharedLink.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => confirmDelete(sharedLink.shareId),
|
||||
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>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShareLinkTable({ className }) {
|
||||
const localize = useLocalize();
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading } =
|
||||
useSharedLinksInfiniteQuery({ pageNumber: '1', isPublic: true }, { enabled: isAuthenticated });
|
||||
|
||||
const { containerRef } = useNavScrolling<SharedLinksResponse>({
|
||||
setShowLoading,
|
||||
hasNextPage: hasNextPage,
|
||||
fetchNextPage: fetchNextPage,
|
||||
isFetchingNextPage: isFetchingNextPage,
|
||||
});
|
||||
|
||||
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
return (
|
||||
<div key={index} className="flex h-10 w-full items-center">
|
||||
<div className="flex w-[410px] items-center">
|
||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</div>
|
||||
<div className="mr-2 flex justify-end">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-300">{skeletons}</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
|
||||
{localize('com_ui_share_retrieve_error')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sharedLinks.length === 0) {
|
||||
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
|
||||
className,
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
|
||||
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
|
||||
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sharedLinks.map((sharedLink) => (
|
||||
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,324 @@
|
|||
import { useLocalize } from '~/hooks';
|
||||
import { OGDialog, OGDialogTrigger } from '~/components/ui';
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogTrigger,
|
||||
OGDialogContent,
|
||||
OGDialogHeader,
|
||||
OGDialogTitle,
|
||||
Button,
|
||||
TooltipAnchor,
|
||||
Label,
|
||||
} from '~/components/ui';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import DataTable from '~/components/ui/DataTable';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { formatDate } from '~/utils';
|
||||
import { Spinner } from '~/components/svg';
|
||||
|
||||
import ShareLinkTable from './SharedLinkTable';
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||
pageSize: PAGE_SIZE,
|
||||
isPublic: true,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
search: '',
|
||||
};
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
const allLinks = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
||||
}, [data?.pages]);
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedRows: SharedLinkItem[]) => {
|
||||
const validRows = selectedRows.filter(
|
||||
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
|
||||
);
|
||||
|
||||
if (validRows.length === 0) {
|
||||
showToast({
|
||||
message: localize('com_ui_no_valid_items'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of validRows) {
|
||||
await deleteMutation.mutateAsync({ shareId: row.shareId });
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_bulk_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteMutation, showToast, localize],
|
||||
);
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (hasNextPage !== true || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() => handleSort('title', column.getIsSorted() === 'asc' ? 'desc' : 'asc')}
|
||||
>
|
||||
{localize('com_ui_name')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { title, shareId } = row.original;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-blue-500 hover:underline"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
size: '35%',
|
||||
mobileSize: '50%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
||||
onClick={() =>
|
||||
handleSort('createdAt', column.getIsSorted() === 'asc' ? 'desc' : 'asc')
|
||||
}
|
||||
>
|
||||
{localize('com_ui_date')}
|
||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
title={localize('com_ui_view_source')}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
></TooltipAnchor>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_shared_links')}</div>
|
||||
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<button className="btn btn-neutral relative ">
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<button className="btn btn-neutral relative">
|
||||
{localize('com_nav_shared_links_manage')}
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_my_files')}
|
||||
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
filterColumn="title"
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogTemplate
|
||||
title={localize('com_nav_shared_links')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ShareLinkTable className="w-full" />}
|
||||
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>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||
}`,
|
||||
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,9 +140,9 @@ const AdminSettings = () => {
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary"
|
||||
>
|
||||
<ShieldEllipsis className="cursor-pointer" />
|
||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Trash2 } from 'lucide-react';
|
||||
import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||
import { TrashIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DeleteVersion = ({
|
||||
|
|
@ -18,14 +18,15 @@ const DeleteVersion = ({
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
size={'sm'}
|
||||
className="h-10 w-10 border border-transparent bg-red-600 text-red-500 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-10 w-10 border border-transparent bg-red-600 transition-all hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-800 p-0.5"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" />
|
||||
<Trash2 className="cursor-pointer text-white size-5" />
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
|
|
|
|||
|
|
@ -256,9 +256,9 @@ const PromptForm = () => {
|
|||
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
||||
{editorMode === PromptsEditorMode.ADVANCED && (
|
||||
<Button
|
||||
size={'sm'}
|
||||
className="h-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600"
|
||||
variant={'default'}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-10 w-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600 p-0.5"
|
||||
onClick={() => {
|
||||
const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt);
|
||||
makeProductionMutation.mutate(
|
||||
|
|
@ -283,7 +283,7 @@ const PromptForm = () => {
|
|||
makeProductionMutation.isLoading
|
||||
}
|
||||
>
|
||||
<Rocket className="cursor-pointer text-white" />
|
||||
<Rocket className="cursor-pointer text-white size-5" />
|
||||
</Button>
|
||||
)}
|
||||
<DeleteConfirm
|
||||
|
|
|
|||
|
|
@ -80,16 +80,18 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'default'}
|
||||
size={'sm'}
|
||||
className="h-10 w-10 border border-transparent bg-blue-500/90 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-10 w-10 border border-transparent bg-blue-500/90 p-0.5 transition-all hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-800"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Share2Icon className="cursor-pointer text-white " />
|
||||
<Share2Icon className="size-5 cursor-pointer text-white" />
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="border-border-light bg-surface-primary-alt text-text-secondary">
|
||||
<OGDialogTitle>{localize('com_ui_share_var', `"${group.name}"`)}</OGDialogTitle>
|
||||
<OGDialogContent className="w-11/12 max-w-[600px]">
|
||||
<OGDialogTitle className="truncate pr-2" title={group.name}>
|
||||
{localize('com_ui_share_var', `"${group.name}"`)}
|
||||
</OGDialogTitle>
|
||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4 flex items-center justify-between gap-2 py-4">
|
||||
<div className="flex items-center">
|
||||
|
|
|
|||
106
client/src/components/ui/AnimatedSearchInput.tsx
Normal file
106
client/src/components/ui/AnimatedSearchInput.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placeholder }) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const isSearching = searching === true;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
|
||||
{/* Background gradient effect */}
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 rounded-lg
|
||||
bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20
|
||||
transition-all duration-500 ease-in-out
|
||||
${isSearching ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'}
|
||||
`}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<Search
|
||||
className={`
|
||||
h-4 w-4 transition-all duration-500 ease-in-out
|
||||
${isFocused ? 'text-blue-500' : 'text-gray-400'}
|
||||
${isSearching ? 'text-blue-400' : ''}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Input field with background transitions */}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
className={`
|
||||
w-full rounded-lg px-10 py-2
|
||||
transition-all duration-500 ease-in-out
|
||||
placeholder:text-gray-400
|
||||
focus:outline-none focus:ring-0
|
||||
${isFocused ? 'bg-white/10' : 'bg-white/5'}
|
||||
${isSearching ? 'bg-white/15' : ''}
|
||||
backdrop-blur-sm
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Animated loading indicator */}
|
||||
<div
|
||||
className={`
|
||||
absolute right-3 top-1/2 -translate-y-1/2
|
||||
transition-all duration-500 ease-in-out
|
||||
${isSearching ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="relative h-2 w-2">
|
||||
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
|
||||
<div className="absolute inset-0 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outer glow effect */}
|
||||
<div
|
||||
className={`
|
||||
absolute -inset-8 -z-10
|
||||
transition-all duration-700 ease-in-out
|
||||
${isSearching ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<div
|
||||
className={`
|
||||
bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent
|
||||
transition-opacity duration-700 ease-in-out
|
||||
${isSearching ? 'animate-pulse-slow opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5
|
||||
blur-xl transition-all duration-700 ease-in-out
|
||||
${isSearching ? 'animate-gradient-x opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus state background glow */}
|
||||
<div
|
||||
className={`
|
||||
absolute inset-0 -z-20 bg-gradient-to-r from-blue-500/10
|
||||
via-purple-500/10 to-blue-500/10 blur-xl
|
||||
transition-all duration-500 ease-in-out
|
||||
${isFocused ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedSearchInput;
|
||||
|
|
@ -15,7 +15,8 @@ const buttonVariants = cva(
|
|||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
submit: 'bg-surface-submit text-text-primary hover:bg-surface-submit/90',
|
||||
// hardcoded text color because of WCAG contrast issues (text-white)
|
||||
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
|
|
|
|||
459
client/src/components/ui/DataTable.tsx
Normal file
459
client/src/components/ui/DataTable.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
Checkbox,
|
||||
TableRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
AnimatedSearchInput,
|
||||
} from './';
|
||||
import { TrashIcon, Spinner } from '~/components/svg';
|
||||
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterColumn?: string;
|
||||
defaultSort?: SortingState;
|
||||
columnVisibilityMap?: Record<string, string>;
|
||||
className?: string;
|
||||
pageSize?: number;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterValue?: string;
|
||||
}
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
row,
|
||||
isSmallScreen,
|
||||
onSelectionChange,
|
||||
index,
|
||||
isSearching,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
isSmallScreen: boolean;
|
||||
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||
index: number;
|
||||
isSearching: boolean;
|
||||
}) => {
|
||||
const handleSelection = useCallback(
|
||||
(value: boolean) => {
|
||||
row.toggleSelected(value);
|
||||
onSelectionChange?.(row.id, value);
|
||||
},
|
||||
[row, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={`
|
||||
motion-safe:animate-fadeIn border-b
|
||||
border-border-light transition-all duration-300
|
||||
ease-out
|
||||
hover:bg-surface-secondary
|
||||
${isSearching ? 'opacity-50' : 'opacity-100'}
|
||||
${isSearching ? 'scale-98' : 'scale-100'}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
if (cell.column.id === 'select') {
|
||||
return (
|
||||
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={handleSelection}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`
|
||||
w-0 max-w-0 px-2 py-1 align-middle text-xs
|
||||
transition-all duration-300 sm:px-4
|
||||
sm:py-2 sm:text-sm
|
||||
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
|
||||
`}
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||
|
||||
function getColumnStyle<TData, TValue>(
|
||||
column: TableColumn<TData, TValue>,
|
||||
isSmallScreen: boolean,
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||
minWidth: column.meta?.minWidth,
|
||||
maxWidth: column.meta?.size,
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
localize,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
localize: (key: string) => string;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onDelete,
|
||||
filterColumn,
|
||||
defaultSort = [],
|
||||
className = '',
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
enableRowSelection = true,
|
||||
showCheckboxes = true,
|
||||
onFilterChange,
|
||||
filterValue,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns;
|
||||
}
|
||||
const selectColumn = {
|
||||
id: 'select',
|
||||
header: ({ table }: { table: TTable<TData> }) => (
|
||||
<div className="flex h-full w-[30px] items-center justify-center">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: Row<TData> }) => (
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
),
|
||||
meta: { size: '50px' },
|
||||
};
|
||||
return [selectColumn, ...columns];
|
||||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 48, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
try {
|
||||
// Safely fetch next page without breaking if lastPage is undefined
|
||||
await fetchNextPage?.();
|
||||
} catch (error) {
|
||||
console.error('Unable to fetch next page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onFilterChange?.(searchTerm);
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchTerm, onFilterChange]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||
await onDelete(itemsToDelete);
|
||||
setRowSelection({});
|
||||
// await fetchNextPage?.({ pageParam: lastPage?.nextCursor });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [onDelete, table]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||
{/* Table controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
|
||||
{enableRowSelection && showCheckboxes && (
|
||||
<DeleteButton
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
localize={localize}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder={`${localize('com_ui_search')}...`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Virtualized table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={cn(
|
||||
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
isSearching && 'bg-surface-secondary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||
style={getColumnStyle(
|
||||
header.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSmallScreen={isSmallScreen}
|
||||
index={virtualRow.index}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!virtualRows.length && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||
{localize('com_ui_no_data')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isFetchingNextPage || hasNextPage) && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
hasNextPage && <div className="h-6" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
OGDialogHeader,
|
||||
OGDialogContent,
|
||||
OGDialogDescription,
|
||||
} from './';
|
||||
} from './OriginalDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export { default as ThemeSelector } from './ThemeSelector';
|
|||
export { default as SelectDropDown } from './SelectDropDown';
|
||||
export { default as MultiSelectPop } from './MultiSelectPop';
|
||||
export { default as ModelParameters } from './ModelParameters';
|
||||
export { default as OGDialogTemplate } from './OGDialogTemplate';
|
||||
export { default as InputWithDropdown } from './InputWithDropDown';
|
||||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue