LibreChat/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx
2025-12-11 16:39:33 -05:00

361 lines
12 KiB
TypeScript

import { useCallback, useState, useMemo, useEffect } from 'react';
import { Trans } from 'react-i18next';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
import {
TrashIcon,
MessageSquare,
ArrowUpDown,
ArrowUp,
ArrowDown,
ExternalLink,
} from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
useToastContext,
OGDialogTemplate,
OGDialogTrigger,
OGDialogContent,
useMediaQuery,
OGDialogHeader,
OGDialogTitle,
DataTable,
Spinner,
Button,
Label,
} from '@librechat/client';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { formatDate } from '~/utils';
import store from '~/store';
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 isSearchEnabled = useRecoilValue(store.search);
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
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 confirmDelete = useCallback(() => {
if (deleteRow) {
handleDelete([deleteRow]);
}
setIsDeleteOpen(false);
}, [deleteRow, handleDelete]);
const columns = useMemo(
() => [
{
accessorKey: 'title',
header: () => {
const isSorted = queryParams.sortBy === 'title';
const sortDirection = queryParams.sortDirection;
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', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-label={localize('com_ui_name_sort')}
>
{localize('com_ui_name')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <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="group flex items-center gap-1 truncate rounded-sm text-blue-600 underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-ring"
title={title}
>
<span className="truncate">{title}</span>
<ExternalLink
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
aria-hidden="true"
/>
</Link>
</div>
);
},
meta: {
size: '35%',
mobileSize: '50%',
},
},
{
accessorKey: 'createdAt',
header: () => {
const isSorted = queryParams.sortBy === 'createdAt';
const sortDirection = queryParams.sortDirection;
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', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
}
aria-label={localize('com_ui_creation_date_sort')}
>
{localize('com_ui_date')}
{isSorted && sortDirection === 'asc' && (
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{isSorted && sortDirection === 'desc' && (
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
)}
{!isSorted && <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">
<a
href={`/c/${row.original.conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center rounded-md p-0 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
>
<MessageSquare className="size-4" aria-hidden="true" />
</a>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
setIsDeleteOpen(true);
}}
aria-label={localize('com_ui_delete_shared_link', {
title: row.original.title || localize('com_ui_untitled'),
})}
aria-haspopup="dialog"
aria-controls="delete-shared-link-dialog"
>
<TrashIcon className="size-4" aria-hidden="true" />
</Button>
</div>
),
},
],
[isSmallScreen, localize, queryParams, handleSort],
);
return (
<div className="flex items-center justify-between">
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button aria-labelledby="shared-links-label" variant="outline">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger>
<OGDialogContent
title={localize('com_nav_shared_links')}
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}
isLoading={isLoading}
enableSearch={isSearchEnabled}
/>
</OGDialogContent>
</OGDialog>
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_shared_link_heading')}
className="max-w-[450px]"
main={
<>
<div
id="delete-shared-link-dialog"
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">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: deleteRow?.title }}
components={{ strong: <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>
);
}