mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 07:08:50 +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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue