diff --git a/api/models/Share.js b/api/models/Share.js index 76649f27b13..041927ec616 100644 --- a/api/models/Share.js +++ b/api/models/Share.js @@ -1,82 +1,71 @@ const { nanoid } = require('nanoid'); const { Constants } = require('librechat-data-provider'); +const { Conversation } = require('~/models/Conversation'); const SharedLink = require('./schema/shareSchema'); const { getMessages } = require('./Message'); const logger = require('~/config/winston'); -/** - * Anonymizes a conversation ID - * @returns {string} The anonymized conversation ID - */ -function anonymizeConvoId() { - return `convo_${nanoid()}`; +class ShareServiceError extends Error { + constructor(message, code) { + super(message); + this.name = 'ShareServiceError'; + this.code = code; + } } -/** - * Anonymizes an assistant ID - * @returns {string} The anonymized assistant ID - */ -function anonymizeAssistantId() { - return `a_${nanoid()}`; -} +const memoizedAnonymizeId = (prefix) => { + const memo = new Map(); + return (id) => { + if (!memo.has(id)) { + memo.set(id, `${prefix}_${nanoid()}`); + } + return memo.get(id); + }; +}; -/** - * Anonymizes a message ID - * @param {string} id - The original message ID - * @returns {string} The anonymized message ID - */ -function anonymizeMessageId(id) { - return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`; -} +const anonymizeConvoId = memoizedAnonymizeId('convo'); +const anonymizeAssistantId = memoizedAnonymizeId('a'); +const anonymizeMessageId = (id) => + id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id); -/** - * Anonymizes a conversation object - * @param {object} conversation - The conversation object - * @returns {object} The anonymized conversation object - */ function anonymizeConvo(conversation) { + if (!conversation) { + return null; + } + const newConvo = { ...conversation }; if (newConvo.assistant_id) { - newConvo.assistant_id = anonymizeAssistantId(); + newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id); } return newConvo; } -/** - * Anonymizes messages in a conversation - * @param {TMessage[]} messages - The original messages - * @param {string} newConvoId - The new conversation ID - * @returns {TMessage[]} The anonymized messages - */ function anonymizeMessages(messages, newConvoId) { + if (!Array.isArray(messages)) { + return []; + } + const idMap = new Map(); return messages.map((message) => { const newMessageId = anonymizeMessageId(message.messageId); idMap.set(message.messageId, newMessageId); - const anonymizedMessage = Object.assign(message, { + return { + ...message, messageId: newMessageId, parentMessageId: idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId), conversationId: newConvoId, - }); - - if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) { - anonymizedMessage.model = anonymizeAssistantId(); - } - - return anonymizedMessage; + model: message.model?.startsWith('asst_') + ? anonymizeAssistantId(message.model) + : message.model, + }; }); } -/** - * Retrieves shared messages for a given share ID - * @param {string} shareId - The share ID - * @returns {Promise<object|null>} The shared conversation data or null if not found - */ async function getSharedMessages(shareId) { try { - const share = await SharedLink.findOne({ shareId }) + const share = await SharedLink.findOne({ shareId, isPublic: true }) .populate({ path: 'messages', select: '-_id -__v -user', @@ -84,165 +73,264 @@ async function getSharedMessages(shareId) { .select('-_id -__v -user') .lean(); - if (!share || !share.conversationId || !share.isPublic) { + if (!share?.conversationId || !share.isPublic) { return null; } - const newConvoId = anonymizeConvoId(); - return Object.assign(share, { + const newConvoId = anonymizeConvoId(share.conversationId); + const result = { + ...share, conversationId: newConvoId, messages: anonymizeMessages(share.messages, newConvoId), - }); + }; + + return result; } catch (error) { - logger.error('[getShare] Error getting share link', error); - throw new Error('Error getting share link'); + logger.error('[getShare] Error getting share link', { + error: error.message, + shareId, + }); + throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR'); } } -/** - * Retrieves shared links for a user - * @param {string} user - The user ID - * @param {number} [pageNumber=1] - The page number - * @param {number} [pageSize=25] - The page size - * @param {boolean} [isPublic=true] - Whether to retrieve public links only - * @returns {Promise<object>} The shared links and pagination data - */ -async function getSharedLinks(user, pageNumber = 1, pageSize = 25, isPublic = true) { - const query = { user, isPublic }; +async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) { try { - const [totalConvos, sharedLinks] = await Promise.all([ - SharedLink.countDocuments(query), - SharedLink.find(query) - .sort({ updatedAt: -1 }) - .skip((pageNumber - 1) * pageSize) - .limit(pageSize) - .select('-_id -__v -user') - .lean(), - ]); + const query = { user, isPublic }; + + if (pageParam) { + if (sortDirection === 'desc') { + query[sortBy] = { $lt: pageParam }; + } else { + query[sortBy] = { $gt: pageParam }; + } + } + + if (search && search.trim()) { + try { + const searchResults = await Conversation.meiliSearch(search); - const totalPages = Math.ceil((totalConvos || 1) / pageSize); + if (!searchResults?.hits?.length) { + return { + links: [], + nextCursor: undefined, + hasNextPage: false, + }; + } + const conversationIds = searchResults.hits.map((hit) => hit.conversationId); + query['conversationId'] = { $in: conversationIds }; + } catch (searchError) { + logger.error('[getSharedLinks] Meilisearch error', { + error: searchError.message, + user, + }); + return { + links: [], + nextCursor: undefined, + hasNextPage: false, + }; + } + } + + const sort = {}; + sort[sortBy] = sortDirection === 'desc' ? -1 : 1; + + if (Array.isArray(query.conversationId)) { + query.conversationId = { $in: query.conversationId }; + } + + const sharedLinks = await SharedLink.find(query) + .sort(sort) + .limit(pageSize + 1) + .select('-__v -user') + .lean(); + + const hasNextPage = sharedLinks.length > pageSize; + const links = sharedLinks.slice(0, pageSize); + + const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined; + + return { + links: links.map((link) => ({ + shareId: link.shareId, + title: link?.title || 'Untitled', + isPublic: link.isPublic, + createdAt: link.createdAt, + conversationId: link.conversationId, + })), + nextCursor, + hasNextPage, + }; + } catch (error) { + logger.error('[getSharedLinks] Error getting shares', { + error: error.message, + user, + }); + throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR'); + } +} + +async function deleteAllSharedLinks(user) { + try { + const result = await SharedLink.deleteMany({ user }); return { - sharedLinks, - pages: totalPages, - pageNumber, - pageSize, + message: 'All shared links deleted successfully', + deletedCount: result.deletedCount, }; } catch (error) { - logger.error('[getShareByPage] Error getting shares', error); - throw new Error('Error getting shares'); + logger.error('[deleteAllSharedLinks] Error deleting shared links', { + error: error.message, + user, + }); + throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR'); } } -/** - * Creates a new shared link - * @param {string} user - The user ID - * @param {object} shareData - The share data - * @param {string} shareData.conversationId - The conversation ID - * @returns {Promise<object>} The created shared link - */ -async function createSharedLink(user, { conversationId, ...shareData }) { +async function createSharedLink(user, conversationId) { + if (!user || !conversationId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + try { - const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean(); - if (share) { - const newConvoId = anonymizeConvoId(); - const sharedConvo = anonymizeConvo(share); - return Object.assign(sharedConvo, { - conversationId: newConvoId, - messages: anonymizeMessages(share.messages, newConvoId), - }); + const [existingShare, conversationMessages] = await Promise.all([ + SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(), + getMessages({ conversationId }), + ]); + + if (existingShare && existingShare.isPublic) { + throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); + } else if (existingShare) { + await SharedLink.deleteOne({ conversationId }); } + const conversation = await Conversation.findOne({ conversationId }).lean(); + const title = conversation?.title || 'Untitled'; + const shareId = nanoid(); - const messages = await getMessages({ conversationId }); - const update = { ...shareData, shareId, messages, user }; - const newShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, { - new: true, - upsert: true, - }).lean(); + await SharedLink.create({ + shareId, + conversationId, + messages: conversationMessages, + title, + user, + }); - const newConvoId = anonymizeConvoId(); - const sharedConvo = anonymizeConvo(newShare); - return Object.assign(sharedConvo, { - conversationId: newConvoId, - messages: anonymizeMessages(newShare.messages, newConvoId), + return { shareId, conversationId }; + } catch (error) { + logger.error('[createSharedLink] Error creating shared link', { + error: error.message, + user, + conversationId, }); + throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); + } +} + +async function getSharedLink(user, conversationId) { + if (!user || !conversationId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + + try { + const share = await SharedLink.findOne({ conversationId, user, isPublic: true }) + .select('shareId -_id') + .lean(); + + if (!share) { + return { shareId: null, success: false }; + } + + return { shareId: share.shareId, success: true }; } catch (error) { - logger.error('[createSharedLink] Error creating shared link', error); - throw new Error('Error creating shared link'); + logger.error('[getSharedLink] Error getting shared link', { + error: error.message, + user, + conversationId, + }); + throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR'); } } -/** - * Updates an existing shared link - * @param {string} user - The user ID - * @param {object} shareData - The share data to update - * @param {string} shareData.conversationId - The conversation ID - * @returns {Promise<object>} The updated shared link - */ -async function updateSharedLink(user, { conversationId, ...shareData }) { +async function updateSharedLink(user, shareId) { + if (!user || !shareId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); + } + try { - const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean(); + const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean(); + if (!share) { - return { message: 'Share not found' }; + throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND'); } - const messages = await getMessages({ conversationId }); - const update = { ...shareData, messages, user }; - const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, { + const [updatedMessages] = await Promise.all([ + getMessages({ conversationId: share.conversationId }), + ]); + + const newShareId = nanoid(); + const update = { + messages: updatedMessages, + user, + shareId: newShareId, + }; + + const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, { new: true, upsert: false, + runValidators: true, }).lean(); - const newConvoId = anonymizeConvoId(); - const sharedConvo = anonymizeConvo(updatedShare); - return Object.assign(sharedConvo, { - conversationId: newConvoId, - messages: anonymizeMessages(updatedShare.messages, newConvoId), - }); + if (!updatedShare) { + throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR'); + } + + anonymizeConvo(updatedShare); + + return { shareId: newShareId, conversationId: updatedShare.conversationId }; } catch (error) { - logger.error('[updateSharedLink] Error updating shared link', error); - throw new Error('Error updating shared link'); + logger.error('[updateSharedLink] Error updating shared link', { + error: error.message, + user, + shareId, + }); + throw new ShareServiceError( + error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link', + error.code || 'SHARE_UPDATE_ERROR', + ); } } -/** - * Deletes a shared link - * @param {string} user - The user ID - * @param {object} params - The deletion parameters - * @param {string} params.shareId - The share ID to delete - * @returns {Promise<object>} The result of the deletion - */ -async function deleteSharedLink(user, { shareId }) { - try { - const result = await SharedLink.findOneAndDelete({ shareId, user }); - return result ? { message: 'Share deleted successfully' } : { message: 'Share not found' }; - } catch (error) { - logger.error('[deleteSharedLink] Error deleting shared link', error); - throw new Error('Error deleting shared link'); +async function deleteSharedLink(user, shareId) { + if (!user || !shareId) { + throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } -} -/** - * Deletes all shared links for a specific user - * @param {string} user - The user ID - * @returns {Promise<object>} The result of the deletion - */ -async function deleteAllSharedLinks(user) { try { - const result = await SharedLink.deleteMany({ user }); + const result = await SharedLink.findOneAndDelete({ shareId, user }).lean(); + + if (!result) { + return null; + } + return { - message: 'All shared links have been deleted successfully', - deletedCount: result.deletedCount, + success: true, + shareId, + message: 'Share deleted successfully', }; } catch (error) { - logger.error('[deleteAllSharedLinks] Error deleting shared links', error); - throw new Error('Error deleting shared links'); + logger.error('[deleteSharedLink] Error deleting shared link', { + error: error.message, + user, + shareId, + }); + throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR'); } } module.exports = { SharedLink, + getSharedLink, getSharedLinks, createSharedLink, updateSharedLink, diff --git a/api/models/schema/shareSchema.js b/api/models/schema/shareSchema.js index 56ecec00c0d..12699a39ec6 100644 --- a/api/models/schema/shareSchema.js +++ b/api/models/schema/shareSchema.js @@ -20,14 +20,6 @@ const shareSchema = mongoose.Schema( index: true, }, isPublic: { - type: Boolean, - default: false, - }, - isVisible: { - type: Boolean, - default: false, - }, - isAnonymous: { type: Boolean, default: true, }, diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 434bbd48d34..e551f4a354e 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -1,6 +1,7 @@ const express = require('express'); const { + getSharedLink, getSharedMessages, createSharedLink, updateSharedLink, @@ -45,29 +46,60 @@ if (allowSharedLinks) { */ router.get('/', requireJwtAuth, async (req, res) => { try { - let pageNumber = req.query.pageNumber || 1; - pageNumber = parseInt(pageNumber, 10); + const params = { + pageParam: req.query.cursor, + pageSize: Math.max(1, parseInt(req.query.pageSize) || 10), + isPublic: isEnabled(req.query.isPublic), + sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt', + sortDirection: ['asc', 'desc'].includes(req.query.sortDirection) + ? req.query.sortDirection + : 'desc', + search: req.query.search + ? decodeURIComponent(req.query.search.trim()) + : undefined, + }; - if (isNaN(pageNumber) || pageNumber < 1) { - return res.status(400).json({ error: 'Invalid page number' }); - } + const result = await getSharedLinks( + req.user.id, + params.pageParam, + params.pageSize, + params.isPublic, + params.sortBy, + params.sortDirection, + params.search, + ); - let pageSize = req.query.pageSize || 25; - pageSize = parseInt(pageSize, 10); + res.status(200).send({ + links: result.links, + nextCursor: result.nextCursor, + hasNextPage: result.hasNextPage, + }); + } catch (error) { + console.error('Error getting shared links:', error); + res.status(500).json({ + message: 'Error getting shared links', + error: error.message, + }); + } +}); - if (isNaN(pageSize) || pageSize < 1) { - return res.status(400).json({ error: 'Invalid page size' }); - } - const isPublic = req.query.isPublic === 'true'; - res.status(200).send(await getSharedLinks(req.user.id, pageNumber, pageSize, isPublic)); +router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { + try { + const share = await getSharedLink(req.user.id, req.params.conversationId); + + return res.status(200).json({ + success: share.success, + shareId: share.shareId, + conversationId: req.params.conversationId, + }); } catch (error) { - res.status(500).json({ message: 'Error getting shared links' }); + res.status(500).json({ message: 'Error getting shared link' }); } }); -router.post('/', requireJwtAuth, async (req, res) => { +router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - const created = await createSharedLink(req.user.id, req.body); + const created = await createSharedLink(req.user.id, req.params.conversationId); if (created) { res.status(200).json(created); } else { @@ -78,11 +110,11 @@ router.post('/', requireJwtAuth, async (req, res) => { } }); -router.patch('/', requireJwtAuth, async (req, res) => { +router.patch('/:shareId', requireJwtAuth, async (req, res) => { try { - const updated = await updateSharedLink(req.user.id, req.body); - if (updated) { - res.status(200).json(updated); + const updatedShare = await updateSharedLink(req.user.id, req.params.shareId); + if (updatedShare) { + res.status(200).json(updatedShare); } else { res.status(404).end(); } @@ -93,14 +125,15 @@ router.patch('/', requireJwtAuth, async (req, res) => { router.delete('/:shareId', requireJwtAuth, async (req, res) => { try { - const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId }); - if (deleted) { - res.status(200).json(deleted); - } else { - res.status(404).end(); + const result = await deleteSharedLink(req.user.id, req.params.shareId); + + if (!result) { + return res.status(404).json({ message: 'Share not found' }); } + + return res.status(200).json(result); } catch (error) { - res.status(500).json({ message: 'Error deleting shared link' }); + return res.status(400).json({ message: error.message }); } }); diff --git a/client/package.json b/client/package.json index ea4d3696009..515f88ae7f3 100644 --- a/client/package.json +++ b/client/package.json @@ -72,6 +72,7 @@ "lucide-react": "^0.394.0", "match-sorter": "^6.3.4", "msedge-tts": "^1.3.4", + "qrcode.react": "^4.2.0", "rc-input-number": "^7.4.2", "react": "^18.2.0", "react-avatar-editor": "^13.0.2", diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index bd019c88516..42b8b2db438 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -141,7 +141,6 @@ function ConvoOptions({ /> {showShareDialog && ( <ShareButton - title={title ?? ''} conversationId={conversationId ?? ''} open={showShareDialog} onOpenChange={setShowShareDialog} diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 4fc47d53c17..c979019f01f 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -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, - }; - - 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 - }, []); + const button = + isLoading === true ? null : ( + <SharedLinkButton + share={share} + conversationId={conversationId} + setShareDialogOpen={onOpenChange} + showQR={showQR} + setShowQR={setShowQR} + setSharedLink={setSharedLink} + /> + ); - 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> } /> diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index fbed2ae1288..4255515745c 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -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, - }); + const updateShare = await mutateAsync({ shareId }); + const newLink = generateShareLink(updateShare.shareId); + setSharedLink(newLink); + }; - if (result) { - setShare(result); - setIsUpdated(true); - copyLink(); - } + const createShareLink = async () => { + const share = await mutate({ conversationId }); + const newLink = generateShareLink(share.shareId); + setSharedLink(newLink); }; - const getHandler = () => { - if (isUpdated) { - return { - handler: () => { - copyLink(); - }, - label: ( - <> - <Copy className="mr-2 h-4 w-4" /> - {localize('com_ui_copy_link')} - </> - ), - }; + + const handleDelete = async () => { + if (!shareId) { + return; } - if (share.isPublic) { - return { - handler: async () => { - await updateSharedLink(); - }, - label: ( - <> - <Link className="mr-2 h-4 w-4" /> - {localize('com_ui_update_link')} - </> - ), - }; + 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, + }); } - return { - handler: updateSharedLink, - label: ( - <> - <Link className="mr-2 h-4 w-4" /> - {localize('com_ui_create_link')} - </> - ), - }; }; - 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> + </> ); } diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx deleted file mode 100644 index ce2c795d7e5..00000000000 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinkTable.tsx +++ /dev/null @@ -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> - ); -} diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 9921ecbfe90..093135ae3c2 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -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> diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index d1a5bcafcae..b3925ca6d45 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -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> diff --git a/client/src/components/Prompts/DeleteVersion.tsx b/client/src/components/Prompts/DeleteVersion.tsx index c613fd63326..9a2800933a4 100644 --- a/client/src/components/Prompts/DeleteVersion.tsx +++ b/client/src/components/Prompts/DeleteVersion.tsx @@ -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 diff --git a/client/src/components/Prompts/PromptForm.tsx b/client/src/components/Prompts/PromptForm.tsx index bf1185168cd..c1ef0049f05 100644 --- a/client/src/components/Prompts/PromptForm.tsx +++ b/client/src/components/Prompts/PromptForm.tsx @@ -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 diff --git a/client/src/components/Prompts/SharePrompt.tsx b/client/src/components/Prompts/SharePrompt.tsx index 9a12553c460..5cca787362a 100644 --- a/client/src/components/Prompts/SharePrompt.tsx +++ b/client/src/components/Prompts/SharePrompt.tsx @@ -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"> diff --git a/client/src/components/ui/AnimatedSearchInput.tsx b/client/src/components/ui/AnimatedSearchInput.tsx new file mode 100644 index 00000000000..422d7f943b3 --- /dev/null +++ b/client/src/components/ui/AnimatedSearchInput.tsx @@ -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; diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx index 7ebe99e7cad..4687db0fbca 100644 --- a/client/src/components/ui/Button.tsx +++ b/client/src/components/ui/Button.tsx @@ -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', diff --git a/client/src/components/ui/DataTable.tsx b/client/src/components/ui/DataTable.tsx new file mode 100644 index 00000000000..4ed41de7f5f --- /dev/null +++ b/client/src/components/ui/DataTable.tsx @@ -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> + ); +} diff --git a/client/src/components/ui/OGDialogTemplate.tsx b/client/src/components/ui/OGDialogTemplate.tsx index 37492d569a5..8194ea8a0ef 100644 --- a/client/src/components/ui/OGDialogTemplate.tsx +++ b/client/src/components/ui/OGDialogTemplate.tsx @@ -6,7 +6,7 @@ import { OGDialogHeader, OGDialogContent, OGDialogDescription, -} from './'; +} from './OriginalDialog'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils/'; diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index e49309486d0..d81a8cad5d8 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -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'; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 6d952bb0603..b3b25c35a44 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -1,26 +1,18 @@ import { Constants, - InfiniteCollections, defaultAssistantsVersion, ConversationListResponse, } from 'librechat-data-provider'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider'; -import type * as t from 'librechat-data-provider'; import type { InfiniteData, UseMutationResult } from '@tanstack/react-query'; +import type * as t from 'librechat-data-provider'; +import { useConversationTagsQuery, useConversationsInfiniteQuery } from './queries'; import useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo'; import { updateConversationTag } from '~/utils/conversationTags'; import { normalizeData } from '~/utils/collection'; -import { - useConversationTagsQuery, - useConversationsInfiniteQuery, - useSharedLinksInfiniteQuery, -} from './queries'; import { logger, - /* Shared Links */ - addSharedLink, - deleteSharedLink, /* Conversations */ addConversation, updateConvoFields, @@ -244,120 +236,126 @@ export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => { }; export const useCreateSharedLinkMutation = ( - options?: t.CreateSharedLinkOptions, -): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => { + options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>, +): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => { const queryClient = useQueryClient(); - const { refetch } = useSharedLinksInfiniteQuery(); + const { onSuccess, ..._options } = options || {}; - return useMutation((payload: t.TSharedLinkRequest) => dataService.createSharedLink(payload), { - onSuccess: (_data, vars, context) => { - if (!vars.conversationId) { - return; + return useMutation( + ({ conversationId }: { conversationId: string }) => { + if (!conversationId) { + throw new Error('Conversation ID is required'); } - const isPublic = vars.isPublic === true; - - queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => { - if (!sharedLink) { - return sharedLink; - } - const pageSize = sharedLink.pages[0].pageSize as number; - return normalizeData( - // If the shared link is public, add it to the shared links cache list - isPublic ? addSharedLink(sharedLink, _data) : deleteSharedLink(sharedLink, _data.shareId), - InfiniteCollections.SHARED_LINKS, - pageSize, - ); - }); + return dataService.createSharedLink(conversationId); + }, + { + onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { + queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data); - queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data); - if (!isPublic) { - const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]); - refetch({ - refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1, - }); - } - onSuccess?.(_data, vars, context); + onSuccess?.(_data, vars, context); + }, + ..._options, }, - ..._options, - }); + ); }; export const useUpdateSharedLinkMutation = ( - options?: t.UpdateSharedLinkOptions, -): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => { + options?: t.MutationOptions<t.TUpdateShareLinkRequest, { shareId: string }>, +): UseMutationResult<t.TSharedLinkResponse, unknown, { shareId: string }, unknown> => { const queryClient = useQueryClient(); - const { refetch } = useSharedLinksInfiniteQuery(); + const { onSuccess, ..._options } = options || {}; - return useMutation((payload: t.TSharedLinkRequest) => dataService.updateSharedLink(payload), { - onSuccess: (_data, vars, context) => { - if (!vars.conversationId) { - return; + return useMutation( + ({ shareId }) => { + if (!shareId) { + throw new Error('Share ID is required'); } + return dataService.updateSharedLink(shareId); + }, + { + onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { + queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data); - const isPublic = vars.isPublic === true; + onSuccess?.(_data, vars, context); + }, + ..._options, + }, + ); +}; - queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => { - if (!sharedLink) { - return sharedLink; - } +export const useDeleteSharedLinkMutation = ( + options?: t.DeleteSharedLinkOptions, +): UseMutationResult< + t.TDeleteSharedLinkResponse, + unknown, + { shareId: string }, + t.DeleteSharedLinkContext +> => { + const queryClient = useQueryClient(); + const { onSuccess } = options || {}; - return normalizeData( - // If the shared link is public, add it to the shared links cache list. - isPublic - ? // Even if the SharedLink data exists in the database, it is not registered in the cache when isPublic is false. - // Therefore, when isPublic is true, use addSharedLink instead of updateSharedLink. - addSharedLink(sharedLink, _data) - : deleteSharedLink(sharedLink, _data.shareId), - InfiniteCollections.SHARED_LINKS, - sharedLink.pages[0].pageSize as number, - ); + return useMutation((vars) => dataService.deleteSharedLink(vars.shareId), { + onMutate: async (vars) => { + await queryClient.cancelQueries({ + queryKey: [QueryKeys.sharedLinks], + exact: false, }); - queryClient.setQueryData([QueryKeys.sharedLinks, _data.shareId], _data); - if (!isPublic) { - const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]); - refetch({ - refetchPage: (page, index) => index === ((current?.pages.length ?? 0) || 1) - 1, + const previousQueries = new Map(); + const queryKeys = queryClient.getQueryCache().findAll([QueryKeys.sharedLinks]); + + queryKeys.forEach((query) => { + const previousData = queryClient.getQueryData(query.queryKey); + previousQueries.set(query.queryKey, previousData); + + queryClient.setQueryData<t.SharedLinkQueryData>(query.queryKey, (old) => { + if (!old?.pages) { + return old; + } + + const updatedPages = old.pages.map((page) => ({ + ...page, + links: page.links.filter((link) => link.shareId !== vars.shareId), + })); + + const nonEmptyPages = updatedPages.filter((page) => page.links.length > 0); + + return { + ...old, + pages: nonEmptyPages, + }; }); - } + }); - onSuccess?.(_data, vars, context); + return { previousQueries }; }, - ..._options, - }); -}; -export const useDeleteSharedLinkMutation = ( - options?: t.DeleteSharedLinkOptions, -): UseMutationResult<t.TDeleteSharedLinkResponse, unknown, { shareId: string }, unknown> => { - const queryClient = useQueryClient(); - const { refetch } = useSharedLinksInfiniteQuery(); - const { onSuccess, ..._options } = options || {}; - return useMutation(({ shareId }) => dataService.deleteSharedLink(shareId), { - onSuccess: (_data, vars, context) => { - if (!vars.shareId) { - return; + onError: (_err, _vars, context) => { + if (context?.previousQueries) { + context.previousQueries.forEach((prevData: unknown, prevQueryKey: unknown) => { + queryClient.setQueryData(prevQueryKey as string[], prevData); + }); } + }, - queryClient.setQueryData([QueryKeys.sharedMessages, vars.shareId], null); - queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (data) => { - if (!data) { - return data; - } - return normalizeData( - deleteSharedLink(data, vars.shareId), - InfiniteCollections.SHARED_LINKS, - data.pages[0].pageSize as number, - ); + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.sharedLinks], + exact: false, }); - const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]); - refetch({ - refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1, + }, + + onSuccess: (data, variables) => { + if (onSuccess) { + onSuccess(data, variables); + } + + queryClient.refetchQueries({ + queryKey: [QueryKeys.sharedLinks], + exact: true, }); - onSuccess?.(_data, vars, context); }, - ..._options, }); }; @@ -575,36 +573,33 @@ export const useDuplicateConversationMutation = ( ): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options ?? {}; - return useMutation( - (payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload), - { - onSuccess: (data, vars, context) => { - const originalId = vars.conversationId ?? ''; - if (originalId.length === 0) { - return; - } - if (data == null) { - return; + return useMutation((payload) => dataService.duplicateConversation(payload), { + onSuccess: (data, vars, context) => { + const originalId = vars.conversationId ?? ''; + if (originalId.length === 0) { + return; + } + if (data == null) { + return; + } + queryClient.setQueryData( + [QueryKeys.conversation, data.conversation.conversationId], + data.conversation, + ); + queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => { + if (!convoData) { + return convoData; } - queryClient.setQueryData( - [QueryKeys.conversation, data.conversation.conversationId], - data.conversation, - ); - queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => { - if (!convoData) { - return convoData; - } - return addConversation(convoData, data.conversation); - }); - queryClient.setQueryData<t.TMessage[]>( - [QueryKeys.messages, data.conversation.conversationId], - data.messages, - ); - onSuccess?.(data, vars, context); - }, - ..._options, + return addConversation(convoData, data.conversation); + }); + queryClient.setQueryData<t.TMessage[]>( + [QueryKeys.messages, data.conversation.conversationId], + data.messages, + ); + onSuccess?.(data, vars, context); }, - ); + ..._options, + }); }; export const useForkConvoMutation = ( diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index 3ed41b762e1..f4efe03f3e7 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -24,7 +24,7 @@ import type { AssistantDocument, TEndpointsConfig, TCheckUserKeyResponse, - SharedLinkListParams, + SharedLinksListParams, SharedLinksResponse, } from 'librechat-data-provider'; import { findPageForConversation } from '~/utils'; @@ -139,31 +139,29 @@ export const useConversationsInfiniteQuery = ( ); }; -export const useSharedLinksInfiniteQuery = ( - params?: SharedLinkListParams, +export const useSharedLinksQuery = ( + params: SharedLinksListParams, config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>, ) => { - return useInfiniteQuery<SharedLinksResponse, unknown>( - [QueryKeys.sharedLinks], - ({ pageParam = '' }) => + const { pageSize, isPublic, search, sortBy, sortDirection } = params; + + return useInfiniteQuery<SharedLinksResponse>({ + queryKey: [QueryKeys.sharedLinks, { pageSize, isPublic, search, sortBy, sortDirection }], + queryFn: ({ pageParam }) => dataService.listSharedLinks({ - ...params, - pageNumber: pageParam?.toString(), - isPublic: params?.isPublic || true, + cursor: pageParam?.toString(), + pageSize, + isPublic, + search, + sortBy, + sortDirection, }), - { - getNextPageParam: (lastPage) => { - const currentPageNumber = Number(lastPage.pageNumber); - const totalPages = Number(lastPage.pages); // Convert totalPages to a number - // If the current page number is less than total pages, return the next page number - return currentPageNumber < totalPages ? currentPageNumber + 1 : undefined; - }, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - ...config, - }, - ); + getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined, + keepPreviousData: true, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 30 * 60 * 1000, // 30 minutes + ...config, + }); }; export const useConversationTagsQuery = ( diff --git a/client/src/hooks/Messages/useCopyToClipboard.ts b/client/src/hooks/Messages/useCopyToClipboard.ts index 06f5e06c5e3..edb00cdb4f3 100644 --- a/client/src/hooks/Messages/useCopyToClipboard.ts +++ b/client/src/hooks/Messages/useCopyToClipboard.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import copy from 'copy-to-clipboard'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; @@ -7,8 +7,20 @@ export default function useCopyToClipboard({ text, content, }: Partial<Pick<TMessage, 'text' | 'content'>>) { + const copyTimeoutRef = useRef<NodeJS.Timeout | null>(null); + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } + }; + }, []); + const copyToClipboard = useCallback( (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + } setIsCopied(true); let messageText = text ?? ''; if (content) { @@ -22,7 +34,7 @@ export default function useCopyToClipboard({ } copy(messageText, { format: 'text/plain' }); - setTimeout(() => { + copyTimeoutRef.current = setTimeout(() => { setIsCopied(false); }, 3000); }, diff --git a/client/src/localization/languages/Ar.ts b/client/src/localization/languages/Ar.ts index 3291fe548fa..56da6cfbe18 100644 --- a/client/src/localization/languages/Ar.ts +++ b/client/src/localization/languages/Ar.ts @@ -52,7 +52,6 @@ export default { com_ui_chats: 'الدردشات', com_ui_share: 'مشاركة', com_ui_copy_link: 'نسخ الرابط', - com_ui_update_link: 'رابط التحديث', com_ui_create_link: 'إنشاء رابط', com_ui_share_link_to_chat: 'شارك الرابط في الدردشة', com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة', diff --git a/client/src/localization/languages/Br.ts b/client/src/localization/languages/Br.ts index acfac0267b7..268d5845411 100644 --- a/client/src/localization/languages/Br.ts +++ b/client/src/localization/languages/Br.ts @@ -293,7 +293,6 @@ export default { com_ui_share_var: 'Compartilhar {0}', com_ui_enter_var: 'Inserir {0}', com_ui_copy_link: 'Copiar link', - com_ui_update_link: 'Atualizar link', com_ui_create_link: 'Criar link', com_ui_share_to_all_users: 'Compartilhar com todos os usuários', com_ui_my_prompts: 'Meus Prompts', diff --git a/client/src/localization/languages/De.ts b/client/src/localization/languages/De.ts index 487abc717ac..0d82a70be14 100644 --- a/client/src/localization/languages/De.ts +++ b/client/src/localization/languages/De.ts @@ -260,7 +260,6 @@ export default { com_ui_share: 'Teilen', com_ui_share_var: '{0} teilen', com_ui_copy_link: 'Link kopieren', - com_ui_update_link: 'Link aktualisieren', com_ui_create_link: 'Link erstellen', com_ui_share_to_all_users: 'Mit allen Benutzern teilen', com_ui_my_prompts: 'Meine Prompts', diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 484a04ca655..45a6e819e6d 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -53,7 +53,7 @@ export default { com_download_expired: '(download expired)', com_download_expires: '(click here to download - expires {0})', com_click_to_download: '(click here to download)', - com_files_number_selected: '{0} of {1} file(s) selected', + com_files_number_selected: '{0} of {1} items(s) selected', com_sidepanel_select_assistant: 'Select an Assistant', com_sidepanel_parameters: 'Parameters', com_sidepanel_assistant_builder: 'Assistant Builder', @@ -354,7 +354,6 @@ export default { com_ui_share_var: 'Share {0}', com_ui_enter_var: 'Enter {0}', com_ui_copy_link: 'Copy link', - com_ui_update_link: 'Update link', com_ui_create_link: 'Create link', com_ui_share_to_all_users: 'Share to all users', com_ui_my_prompts: 'My Prompts', @@ -379,6 +378,8 @@ export default { com_ui_share_error: 'There was an error sharing the chat link', com_ui_share_retrieve_error: 'There was an error retrieving the shared links', com_ui_share_delete_error: 'There was an error deleting the shared link', + com_ui_bulk_delete_error: 'Failed to delete shared links', + com_ui_bulk_delete_partial_error: 'Failed to delete {0} shared links', com_ui_share_create_message: 'Your name and any messages you add after sharing stay private.', com_ui_share_created_message: 'A shared link to your chat has been created. Manage previously shared chats at any time via Settings.', @@ -442,6 +443,14 @@ export default { com_ui_add_multi_conversation: 'Add multi-conversation', com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?', com_ui_page: 'Page', + com_ui_refresh_link: 'Refresh link', + com_ui_show_qr: 'Show QR Code', + com_ui_hide_qr: 'Hide QR Code', + com_ui_title: 'Title', + com_ui_view_source: 'View source chat', + com_ui_shared_link_delete_success: 'Successfully deleted shared link', + com_ui_shared_link_bulk_delete_success: 'Successfully deleted shared links', + com_ui_search: 'Search', com_auth_error_login: 'Unable to login with the information provided. Please check your credentials and try again.', com_auth_error_login_rl: diff --git a/client/src/localization/languages/Es.ts b/client/src/localization/languages/Es.ts index 28ed671d5ff..33121ea9e8f 100644 --- a/client/src/localization/languages/Es.ts +++ b/client/src/localization/languages/Es.ts @@ -142,7 +142,6 @@ export default { com_ui_create: 'Crear', com_ui_share: 'Compartir', com_ui_copy_link: 'Copiar enlace', - com_ui_update_link: 'Actualizar enlace', com_ui_create_link: 'Crear enlace', com_ui_share_link_to_chat: 'Compartir enlace en el chat', com_ui_share_error: 'Hubo un error al compartir el enlace del chat', diff --git a/client/src/localization/languages/Fi.ts b/client/src/localization/languages/Fi.ts index 67f0321d8f3..9c66993cb7d 100644 --- a/client/src/localization/languages/Fi.ts +++ b/client/src/localization/languages/Fi.ts @@ -243,7 +243,6 @@ export default { com_ui_share: 'Jaa', com_ui_share_var: 'Jaa {0}', com_ui_copy_link: 'Kopioi linkki', - com_ui_update_link: 'Päivitä linkki', com_ui_create_link: 'Luo linkki', com_ui_share_to_all_users: 'Jaa kaikille käyttäjille', com_ui_my_prompts: 'Omat syötteet', diff --git a/client/src/localization/languages/Fr.ts b/client/src/localization/languages/Fr.ts index d7955c9f4fd..0c9801fbdc2 100644 --- a/client/src/localization/languages/Fr.ts +++ b/client/src/localization/languages/Fr.ts @@ -401,7 +401,6 @@ export default { com_ui_copied: 'Copié !', com_ui_copy_code: 'Copier le code', com_ui_copy_link: 'Copier le lien', - com_ui_update_link: 'Mettre à jour le lien', com_ui_create_link: 'Créer un lien', com_nav_source_chat: 'Afficher la conversation source', com_ui_date_today: 'Aujourd\'hui', diff --git a/client/src/localization/languages/He.ts b/client/src/localization/languages/He.ts index 7f98e2fa28e..a73e890fd78 100644 --- a/client/src/localization/languages/He.ts +++ b/client/src/localization/languages/He.ts @@ -96,7 +96,6 @@ export default { com_ui_create: 'צור', com_ui_share: 'שתף', com_ui_copy_link: 'העתק קישור', - com_ui_update_link: 'עדכן קישור', com_ui_create_link: 'צור קישור', com_ui_share_link_to_chat: 'שתף קישור בצ\'אט', com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט', diff --git a/client/src/localization/languages/Id.ts b/client/src/localization/languages/Id.ts index baecfc5abab..fb4f580cee4 100644 --- a/client/src/localization/languages/Id.ts +++ b/client/src/localization/languages/Id.ts @@ -61,7 +61,6 @@ export default { com_ui_chats: 'chat', com_ui_share: 'Bagikan', com_ui_copy_link: 'Salin tautan', - com_ui_update_link: 'Perbarui tautan', com_ui_create_link: 'Buat tautan', com_ui_share_link_to_chat: 'Bagikan tautan ke chat', com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat', diff --git a/client/src/localization/languages/It.ts b/client/src/localization/languages/It.ts index d82334cf430..fb8c9661b00 100644 --- a/client/src/localization/languages/It.ts +++ b/client/src/localization/languages/It.ts @@ -192,7 +192,6 @@ export default { com_ui_create: 'Crea', com_ui_share: 'Condividi', com_ui_copy_link: 'Copia link', - com_ui_update_link: 'Aggiorna link', com_ui_create_link: 'Crea link', com_ui_share_link_to_chat: 'Condividi link a chat', com_ui_share_error: 'Si è verificato un errore durante la condivisione del link della chat', diff --git a/client/src/localization/languages/Jp.ts b/client/src/localization/languages/Jp.ts index fc2a1b2410c..5b2f17a23db 100644 --- a/client/src/localization/languages/Jp.ts +++ b/client/src/localization/languages/Jp.ts @@ -291,7 +291,6 @@ export default { com_ui_share_var: '{0} を共有', com_ui_enter_var: '{0} を入力', com_ui_copy_link: 'リンクをコピー', - com_ui_update_link: 'リンクを更新する', com_ui_create_link: 'リンクを作成する', com_ui_share_to_all_users: '全ユーザーと共有', com_ui_my_prompts: 'マイ プロンプト', diff --git a/client/src/localization/languages/Ko.ts b/client/src/localization/languages/Ko.ts index 2422cd5f63c..63029d895ba 100644 --- a/client/src/localization/languages/Ko.ts +++ b/client/src/localization/languages/Ko.ts @@ -51,7 +51,6 @@ export default { com_ui_chats: '채팅', com_ui_share: '공유하기', com_ui_copy_link: '링크 복사', - com_ui_update_link: '링크 업데이트', com_ui_create_link: '링크 만들기', com_ui_share_link_to_chat: '채팅으로 링크 공유하기', com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다', diff --git a/client/src/localization/languages/Nl.ts b/client/src/localization/languages/Nl.ts index 8518a4d8126..a31b9bf14fa 100644 --- a/client/src/localization/languages/Nl.ts +++ b/client/src/localization/languages/Nl.ts @@ -55,7 +55,6 @@ export default { com_ui_chats: 'chats', com_ui_share: 'Delen', com_ui_copy_link: 'Link kopiëren', - com_ui_update_link: 'Link bijwerken', com_ui_create_link: 'Link aanmaken', com_ui_share_link_to_chat: 'Deel link naar chat', com_ui_share_error: 'Er is een fout opgetreden bij het delen van de chatlink', diff --git a/client/src/localization/languages/Pl.ts b/client/src/localization/languages/Pl.ts index bfa590738df..814be45bb6b 100644 --- a/client/src/localization/languages/Pl.ts +++ b/client/src/localization/languages/Pl.ts @@ -32,7 +32,6 @@ export default { 'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc', com_ui_share: 'Udostępnij', com_ui_copy_link: 'Skopiuj link', - com_ui_update_link: 'Zaktualizuj link', com_ui_create_link: 'Utwórz link', com_ui_share_link_to_chat: 'Udostępnij link w czacie', com_ui_share_error: 'Wystąpił błąd podczas udostępniania linku do czatu', diff --git a/client/src/localization/languages/Ru.ts b/client/src/localization/languages/Ru.ts index 5c7d622c524..8b031746981 100644 --- a/client/src/localization/languages/Ru.ts +++ b/client/src/localization/languages/Ru.ts @@ -70,7 +70,6 @@ export default { com_ui_connect: 'Подключить', com_ui_share: 'Поделиться', com_ui_copy_link: 'Скопировать ссылку', - com_ui_update_link: 'Обновить ссылку', com_ui_create_link: 'Создать ссылку', com_ui_share_link_to_chat: 'Поделиться ссылкой в чате', com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат', diff --git a/client/src/localization/languages/Sv.ts b/client/src/localization/languages/Sv.ts index 67691f127f7..bb39216312f 100644 --- a/client/src/localization/languages/Sv.ts +++ b/client/src/localization/languages/Sv.ts @@ -52,7 +52,6 @@ export default { com_ui_chats: 'chattar', com_ui_share: 'Dela', com_ui_copy_link: 'Kopiera länk', - com_ui_update_link: 'Uppdatera länk', com_ui_create_link: 'Skapa länk', com_ui_share_link_to_chat: 'Dela länk till chatt', com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken', diff --git a/client/src/localization/languages/Tr.ts b/client/src/localization/languages/Tr.ts index 6ba2be2a89d..8731c7f38a9 100644 --- a/client/src/localization/languages/Tr.ts +++ b/client/src/localization/languages/Tr.ts @@ -221,7 +221,6 @@ export default { com_ui_create: 'Oluştur', com_ui_share: 'Paylaş', com_ui_copy_link: 'Bağlantıyı kopyala', - com_ui_update_link: 'Bağlantıyı güncelle', com_ui_create_link: 'Bağlantı oluştur', com_ui_share_link_to_chat: 'Sohbete bağlantı paylaş', com_ui_share_error: 'Sohbet bağlantısını paylaşırken bir hata oluştu', diff --git a/client/src/localization/languages/Vi.ts b/client/src/localization/languages/Vi.ts index db207ddfc17..32065ebc946 100644 --- a/client/src/localization/languages/Vi.ts +++ b/client/src/localization/languages/Vi.ts @@ -54,7 +54,6 @@ export default { com_ui_chats: 'cuộc trò chuyện', com_ui_share: 'Chia sẻ', com_ui_copy_link: 'Sao chép liên kết', - com_ui_update_link: 'Cập nhật liên kết', com_ui_create_link: 'Tạo liên kết', com_ui_share_link_to_chat: 'Chia sẻ liên kết đến cuộc trò chuyện', com_ui_share_error: 'Có lỗi xảy ra khi chia sẻ liên kết trò chuyện', diff --git a/client/src/localization/languages/Zh.ts b/client/src/localization/languages/Zh.ts index 9e5024160c1..3c23ca36bea 100644 --- a/client/src/localization/languages/Zh.ts +++ b/client/src/localization/languages/Zh.ts @@ -278,7 +278,6 @@ export default { com_ui_share_var: '共享 {0}', com_ui_enter_var: '输入 {0}', com_ui_copy_link: '复制链接', - com_ui_update_link: '更新链接', com_ui_create_link: '创建链接', com_ui_share_to_all_users: '共享给所有用户', com_ui_my_prompts: '我的提示词', diff --git a/client/src/localization/languages/ZhTraditional.ts b/client/src/localization/languages/ZhTraditional.ts index eb551b0e648..71d8bcb8059 100644 --- a/client/src/localization/languages/ZhTraditional.ts +++ b/client/src/localization/languages/ZhTraditional.ts @@ -51,7 +51,6 @@ export default { com_ui_chats: '對話', com_ui_share: '分享', com_ui_copy_link: '複製連結', - com_ui_update_link: '更新連結', com_ui_create_link: '建立連結', com_ui_share_link_to_chat: '分享連結到聊天', com_ui_share_error: '分享聊天連結時發生錯誤', diff --git a/client/src/style.css b/client/src/style.css index e190603fd98..b57c9bdcf26 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -60,7 +60,8 @@ html { --surface-tertiary: var(--gray-100); --surface-tertiary-alt: var(--white); --surface-dialog: var(--white); - --surface-submit: var(--green-500); + --surface-submit: var(--green-700); + --surface-submit-hover: var(--green-800); --border-light: var(--gray-200); --border-medium-alt: var(--gray-300); --border-medium: var(--gray-300); @@ -114,7 +115,8 @@ html { --surface-tertiary: var(--gray-700); --surface-tertiary-alt: var(--gray-700); --surface-dialog: var(--gray-850); - --surface-submit: var(--green-600); + --surface-submit: var(--green-700); + --surface-submit-hover: var(--green-800); --border-light: var(--gray-700); --border-medium-alt: var(--gray-600); --border-medium: var(--gray-600); @@ -2412,3 +2414,42 @@ button.scroll-convo { height: auto !important; max-height: none !important; } + +/** AnimatedSearchInput style */ + +@keyframes gradient-x { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.animate-gradient-x { + background-size: 200% 200%; + animation: gradient-x 15s ease infinite; +} + +.animate-pulse-subtle { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.animate-pulse-slow { + animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out forwards; +} + +.scale-98 { + transform: scale(0.98); +} \ No newline at end of file diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index e11dd0443dc..5d2991abd5f 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -11,7 +11,6 @@ export * from './textarea'; export * from './messages'; export * from './languages'; export * from './endpoints'; -export * from './sharedLink'; export * from './localStorage'; export * from './promptGroups'; export { default as cn } from './cn'; diff --git a/client/src/utils/sharedLink.fakeData.ts b/client/src/utils/sharedLink.fakeData.ts deleted file mode 100644 index 947a26fe2de..00000000000 --- a/client/src/utils/sharedLink.fakeData.ts +++ /dev/null @@ -1,955 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -import type { SharedLinkListData } from 'librechat-data-provider'; - -const today = new Date(); -today.setDate(today.getDate() - 3); - -export const sharedLinkData: SharedLinkListData = { - pages: [ - { - sharedLinks: [ - { - conversationId: '7a327f49-0850-4741-b5da-35373e751256', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-04T04:31:04.897Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'b3c2e29b131c464182b483c4', - '6dc217152a134ac1826fc46c', - '483658114d104691b2501fbf', - 'cfb8467cfd30438e8268cf92', - ], - shareId: '62f850ad-a0d8-48a5-b439-2d1dbaba291c', - title: 'Test Shared Link 1', - updatedAt: '2024-04-11T11:10:42.329Z', - }, - { - conversationId: '1777ad5f-5e53-4847-be49-86f66c649ac6', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-05T05:59:31.571Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'bc53fda136ba46fb965260b8', - '138b83d659c84250904feb53', - '1c750ffab31546bd85b81360', - '7db87f183e4d489fae0b5161', - '64ee2004479644b7b5ffd2ea', - '4dd2b9a0704c4ae79688292e', - '25394c2bb2ee40feaf67836f', - '838ed537d9054780a3d9f272', - '300728390f8c4021a6c066ca', - 'ea30b637cb8f463192523919', - ], - shareId: '1f43f69f-0562-4129-b181-3c37df0df43e', - title: 'Test Shared Link 2', - updatedAt: '2024-04-16T17:52:40.250Z', - }, - { - conversationId: 'a9682067-a7c9-4375-8efb-6b8fa1c71def', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-03T08:23:35.147Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'bb4fe223548b480eae6d64af', - '420ef02293d0470b96980e7b', - 'ae0ffbb27e13418fbd63e7c2', - '43df3ea55cfb4219b1630518', - 'c4fb3be788404058a4c9780d', - '6ee6a5833b1d4849a95be890', - '0b8a3ecf5ca5449b9bdc0ed8', - 'a3daed97f0e5432a8b6031c0', - '6a7d10c55c9a46cfbd08d6d2', - '216d40fa813a44059bd01ab6', - ], - shareId: 'e84d2642-9b3a-4e20-b92a-11a37eebe33f', - title: 'Test Shared Link 3', - updatedAt: '2024-02-06T04:21:17.065Z', - }, - { - conversationId: 'b61f9a0a-6d5d-4d0e-802b-4c1866428816', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-06T19:25:45.708Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: ['00aad718514044dda8e044ec', '8cb3b67fccd64b8c8ac0abbb'], - shareId: '9011e12a-b2fe-4003-9623-bf1b5f80396b', - title: 'Test Shared Link 4', - updatedAt: '2024-03-21T22:37:32.704Z', - }, - { - conversationId: '4ac3fd9e-437b-4988-b870-29cacf28abef', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-03T15:45:11.220Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '6b05f825ca7747f294f2ac64', - '871ee06fb6a141879ca1cb25', - '47b05821c6134a3b9f21072e', - ], - shareId: '51d3ab25-195e-47d0-a5e3-d0694ece776a', - title: 'Test Shared Link 5', - updatedAt: '2024-04-03T23:20:11.213Z', - }, - { - conversationId: '6ed26de8-3310-4abb-b561-4bdae9400aac', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-22T19:12:14.995Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'ac2929efa82b4cd78aae02d6', - '4266450abc7b41a59887e99d', - '95df3c7c802c40e0b643bb96', - 'f21038af46074e51a2c4bd87', - '3f064bc8589c435786a92bcb', - ], - shareId: 'c3bc13ed-190a-4ffa-8a05-50f8dad3c83e', - title: 'Test Shared Link 6', - updatedAt: '2024-04-25T19:55:25.785Z', - }, - { - conversationId: 'b3c0aaca-ee76-42a2-b53b-5e85baca2f91', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-04T00:37:12.929Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '5a44ebd0bf05418e98cc9e5d', - '88b93127aef74bfb94666ac1', - 'bf654993c34743c9a5a1b76c', - '2514259bd702491e924da475', - '60dbbf91a6734aa081e082cd', - '11efabaa3a8f4df8bf85410b', - '3f5bbf38abdb42efa65a8740', - '5b9dd8246dde41ae9ebd57c4', - ], - shareId: '871d41fe-fb8a-41d4-8460-8bb93fb8aa98', - title: 'Test Shared Link 7', - updatedAt: '2024-03-13T14:34:26.790Z', - }, - { - conversationId: '2071122a-57cc-4f16-baa8-1e8af3e23522', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-24T03:22:58.012Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '8c94aad21caa45d6acb863c8', - 'c10e4e0bfe554a94920093ba', - '2e4c2e2238f24f63b08440bc', - '05bacd00320342298f9f439f', - 'c8b7750a7d8a4e2fbdc2630b', - 'a84573fea668476a87207979', - '6ab15a1b96c24798b1bddd6f', - 'b699d8e42324493eae95ca44', - ], - shareId: 'f90f738a-b0ac-4dba-bb39-ad3d77919a21', - title: 'Test Shared Link 8', - updatedAt: '2024-01-22T11:09:51.834Z', - }, - { - conversationId: 'ee06374d-4452-4fbe-a1c0-5dbc327638f9', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-03T19:24:21.281Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '297d0827c81a4da0a881561a', - '3131ef1b3c484542b0db1f92', - 'e8879a50340c49449e970dbc', - 'fe598327a93b4b0399055edd', - 'acc7a2a24e204325befffbcd', - '6ec3c6450e124cbf808c8839', - '714e3443f62045aaaff17f93', - '014be593aaad41cab54a1c44', - ], - shareId: '0fc91bab-083d-449f-add3-1e32146b6c4a', - title: 'Test Shared Link 9', - updatedAt: '2024-03-14T00:52:52.345Z', - }, - { - conversationId: '0d2f6880-cacf-4f7b-930e-35881df1cdea', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-14T03:18:45.587Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: ['1d045c1cf37742a6a979e21b'], - shareId: 'd87deb62-b993-476c-b520-104b08fd7445', - title: 'Test Shared Link 10', - updatedAt: '2024-03-26T18:38:41.222Z', - }, - { - conversationId: '1fe437fd-68f0-4e3e-81a9-ca9a0fa8220a', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-16T19:55:23.412Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a28b5f27c95e4700bcd158dc', - '6e85f0a8b6ae4107a5819317', - 'fa5b863c91224a0098aebd64', - 'b73811a510e54acebe348371', - 'f3f7f7d7b69a485da727f9c2', - '81d82df3098c4e359d29703f', - ], - shareId: '704a1a9c-5366-4f55-b69e-670a374f4326', - title: 'Test Shared Link 11', - updatedAt: '2024-04-11T05:00:25.349Z', - }, - { - conversationId: '50465c8e-102f-4f94-88c2-9cf607a6c336', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-05T21:57:52.289Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a64886199ab641c29eb6fdaf', - '9c16497010354cf385d4cc1d', - '36cdeb4d1e4f45078edfe28a', - 'a11f4ea78fa44f57bfc5bfc6', - 'dea42fcfe7a544feb5debc26', - 'ece0d630cd89420ca80ffe25', - '719165a5d80644ae8fae9498', - 'f27111921a10470982f522b2', - '10b78255f7a24b6192e67693', - ], - shareId: 'e47eaf30-c1ed-4cc2-b2b8-8cdec4b1ea2f', - title: 'Test Shared Link 12', - updatedAt: '2024-02-07T15:43:21.110Z', - }, - { - conversationId: '1834f551-0a68-4bc7-a66a-21a234462d24', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-23T02:58:52.653Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'cb5d19d986194f779a6f47fd', - '72159d6668f347f99398aec9', - 'cbe535213d664a6280d9a19e', - '8dccceadcb3a44148962ba47', - ], - shareId: '976b55cb-d305-40f8-ae06-ae516f4e49f5', - title: 'Test Shared Link 13', - updatedAt: '2024-05-02T10:21:05.190Z', - }, - { - conversationId: 'd8175b2f-f7c0-4f61-850d-f524bf8a84df', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-09T09:04:10.576Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '0f3708fc670d46998b1294d5', - '794520b9cee84c23bff01d5a', - 'b05d2af2d37c426a970d8326', - 'bd4239e379284d01acb9aaf4', - 'e6265cfbbd88420781b27248', - '5262193aef7c426cafe2ee85', - '848569e2ca4843beaf64efc4', - '99f3b438241c4454a6784ac2', - '111d346fbeae4806bdf23490', - 'fe4bde34e1a143f1a12fa628', - ], - shareId: '928eb0a8-e0ea-470d-8b6a-92e0981d61b0', - title: 'Test Shared Link 14', - updatedAt: '2024-04-15T18:00:13.094Z', - }, - { - conversationId: '281984c0-fed0-4428-8e50-c7c93cba4ae0', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-23T23:26:41.956Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '7e781fd08408426795c243e7', - '3c6d729fd3524a65a7b2a5e3', - '53bdbec6ee6148e78795d6e1', - '46f8170f28684ccc8ee56f33', - '3350d9aa7c814c89af6d3640', - ], - shareId: '7a251af6-1ad3-4b24-830c-21b38124f325', - title: 'Test Shared Link 15', - updatedAt: '2024-03-18T16:33:35.498Z', - }, - { - conversationId: '09610b11-6087-4d15-b163-e1bc958f2e82', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-05T20:00:36.159Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: ['6dce61720af24c70926efe87'], - shareId: '2b389d5e-eb24-4b29-a8e1-c0545cdfa1fc', - title: 'Test Shared Link 16', - updatedAt: '2024-02-23T05:49:50.020Z', - }, - { - conversationId: '0c388322-905c-4c57-948c-1ba9614fdc2f', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-05T00:03:20.078Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'e3755ff2cf9f403c9d20901f', - 'e32733b8da1440ec9d9dc2df', - 'e2870d0361634d4f867e1e57', - '2e504afb8675434bb9f58cb5', - 'ea38d76735c54f94bf378ed3', - '8712cda1bfc8480eba6c65aa', - '3f43a655706f4032a9e1efb4', - '3f890f8279f4436da2a7d767', - '4ca7616c04404391a7cfc94f', - 'd3e176a831ff48e49debabce', - ], - shareId: '2866400b-bcb9-43a4-8cbf-6597959f8c55', - title: 'Test Shared Link 17', - updatedAt: '2024-03-16T02:53:06.642Z', - }, - { - conversationId: '5ac2b90a-63f8-4388-919b-40a1c1fea874', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-21T15:30:37.893Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '734a0427c6224fca87e2a89d', - '6af13387ddf0495d9c6ebad9', - '02a93d5659f343678b12b932', - '8af2f028c5114286a3339075', - '3a8bec13fc574fb9a9f938e2', - '6f4aa482286548b7b42668e6', - 'c1d4f94a2eaf4e44b94c5834', - '442d9491b51d49fcab60366d', - '82a115a84b2a4457942ca6cf', - '152d8c2894a0454d9248c9f5', - ], - shareId: 'e76f6a90-06f3-4846-8e3d-987d37af27b5', - title: 'Test Shared Link 18', - updatedAt: '2024-01-27T06:25:27.032Z', - }, - { - conversationId: '01521fef-aa0b-4670-857d-f19bfc0ce664', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-01T21:46:40.674Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '222cf562d8e24b1b954395c2', - 'c6f299f588c24905b771e623', - 'f023f30fd4d9472c9bf60b84', - 'e4929e3f14d748a18656f1be', - 'a01f453fcb0a49b5b488a22c', - '4ceee6b365ab4386bacb4d27', - 'c2cab81da0be4c6e97f11f92', - '644c32d10f2f4e2086d5e04d', - '5225d1286db14cc6a47fdea5', - 'c821ebb220ae495b98f2e17f', - ], - shareId: '1b2d8bf5-ff90-478a-bdf6-ea622fb4875a', - title: 'Test Shared Link 19', - updatedAt: '2024-02-25T15:52:56.189Z', - }, - { - conversationId: '54f5d332-efc7-4062-9e1d-c70c3dbbc964', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-29T15:57:22.808Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '49771038e2dd4de0a28b19f2', - '0debd4ad13de4db9a65fe589', - 'a9c8e6e34c34486ca27b7c88', - 'd7b0ace0438146789e8b1899', - ], - shareId: '4f5eea7d-b3a8-4b72-ad1e-a4d516c582c2', - title: 'Test Shared Link 20', - updatedAt: '2024-03-18T13:12:10.828Z', - }, - { - conversationId: '99dabf25-46a5-43bb-8274-715c64e56032', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-05T03:35:11.327Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: ['965a55515727404eb70dec8f'], - shareId: '2360b7c1-20d7-46b9-919d-65576a899ab9', - title: 'Test Shared Link 21', - updatedAt: '2024-04-17T11:22:12.800Z', - }, - { - conversationId: '1e2ffc1a-3546-460e-819c-689eb88940c6', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-22T08:40:32.663Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '131f4b03ad3d4e90803a203d', - '7f55262c554f4d97a8fef0ec', - '341e8fea28e241fc8b5a2398', - ], - shareId: 'f3e370ed-420c-4579-a033-e18743b49485', - title: 'Test Shared Link 22', - updatedAt: '2024-04-07T22:06:07.162Z', - }, - { - conversationId: '14510a0c-01cc-4bfb-8336-3388573ac4d8', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-08T08:20:28.619Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '022f87b1bf0d4e4688970daa', - '42519e8f3603496faae0969c', - 'abc29ac88d66485aa11e4b58', - ], - shareId: '0f46f1fd-95d3-4a6f-a5aa-ae5338dc5337', - title: 'Test Shared Link 23', - updatedAt: '2024-03-06T12:05:33.679Z', - }, - { - conversationId: '2475594e-10dc-4d6a-aa58-5ce727a36271', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-04T07:43:46.952Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '5d0cd8bef4c241aba5d822a8', - 'a19669a364d84ab5bbafbe0c', - '336686022ea6456b9a63879d', - '3323c9b85acc4ffba35aad04', - 'bf15e8860a01474cb4744842', - '5a055eb825ed4173910fffd5', - '36a5e683ad144ec68c2a8ce0', - '8bc1d5590a594fa1afc18ee1', - 'f86444b60bea437ba0d0ef8e', - '5be768788d984723aef5c9a0', - ], - shareId: 'b742f35c-e6a3-4fa4-b35d-abab4528d7d6', - title: 'Test Shared Link 24', - updatedAt: '2024-03-27T15:31:10.930Z', - }, - { - conversationId: 'ddb5a61c-82fe-4cc7-a2b0-c34b8c393b28', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-15T02:06:45.901Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '059d7ae5405a42af9c52171d', - '303efd2e676e4fe7aa9fa9d0', - '9f459c2e6a23411ea4a3e153', - '6036a3785adc4b7caa7ea22b', - '65251979d0c64d1f8821b3d9', - '25fdeb5ed99d42cca3041e08', - '61baa25e4e3d42a3aefd6c16', - '91dc4578fee749aeb352b5ea', - 'd52daca5afb84e7890d5d9ad', - ], - shareId: '13106e5f-1b5f-4ed4-963d-790e61c1f4c8', - title: 'Test Shared Link 25', - updatedAt: '2024-02-05T08:39:45.847Z', - }, - { - conversationId: 'df09c89b-0b0d-429c-9c93-b5f4d51ef1ec', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-28T07:50:10.724Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '7f007af305ee4f2197c125d3', - '318b26abfe864dbf9f557bf9', - '0c4709b489ac4211b9f22874', - '8940f9ab45f44b56911819d5', - 'b47ec3aa0cf7413fa446f19b', - '3857f85f492f4e11aa0ea377', - ], - shareId: '31bbafa4-2051-4a20-883b-2f8557c46116', - title: 'Test Shared Link 26', - updatedAt: '2024-02-01T19:52:32.986Z', - }, - { - conversationId: '856a4d54-54f7-483f-9b4e-7b798920be25', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-14T08:57:03.592Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'b5afc1f3569d44378bc7539d', - 'e54804329577443d8685d3b1', - '7b10204ad48c464aac2b752a', - '8e96d562d33b4d6b85f2269e', - 'cd844644f15d4dbdb5772a3b', - '91f5159278ca420c8a0097b2', - '5f8cf34736df4cca962635c1', - '96e2169ddcf5408fb793aeb6', - '988d96959afb4ec08cd3cec4', - '173398cdf05d4838aeb5ad9f', - ], - shareId: '88c159a0-0273-4798-9d21-f95bd650bd30', - title: 'Test Shared Link 27', - updatedAt: '2024-05-08T20:07:46.345Z', - }, - { - conversationId: '41ee3f3f-36a5-4139-993a-1c4d7d055ccb', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-26T10:08:29.943Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: ['883cc3286240405ba558f322', '7ca7809f471e481fa9944795'], - shareId: '97dc26aa-c909-4a9c-91be-b605d25b9cf3', - title: 'Test Shared Link 28', - updatedAt: '2024-04-06T17:36:05.767Z', - }, - { - conversationId: '79e30f91-9b87-484c-8a12-6e4c6e8973d4', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-05-07T05:28:58.595Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a8ac347785504b51bdad7ea7', - 'ce85321aecf64355b0362f8c', - '21a462895f37474d8d6acdfd', - '095d9104011e4534bda93294', - '503b6e27677c457289366a8d', - '1738d52a60004c9ba6f0c9ec', - 'a157fe44a67f4882a507941b', - '40e30dc275394eb4b9921db0', - 'f4ed9f2fb08640fcbacaa6a7', - 'bbac358328864dc2bfaa39da', - ], - shareId: 'aa36fc45-2a73-4fa2-a500-2a9148fca67d', - title: 'Test Shared Link 29', - updatedAt: '2024-01-26T16:45:59.269Z', - }, - { - conversationId: 'f5eaa000-3657-43d4-bc55-538108723b83', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-22T15:51:31.330Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a87cfce565844b4ba9230dc5', - '426723bc4c22425e9bdf4b7b', - '73be5795469a444b8f1eca88', - '75a87212574a4cfc80d7d4e3', - '80f982dfc3e94535aed6e7d4', - '86d036c912c142ca8ec0f45a', - 'e3435fbbd4d2443eba30e97d', - 'e451e124aa964398b596af5d', - '1a13913f55e9442e8b5d7816', - ], - shareId: 'fe0f7ea2-74d2-40ba-acb2-437e61fc3bef', - title: 'Test Shared Link 30', - updatedAt: '2024-02-27T13:29:04.060Z', - }, - { - conversationId: 'a1ad92b4-6fac-44be-bad6-7648aeeba7af', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-10T09:32:22.242Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '5931cd94fbcd4fbcbaa20b91', - '0bc5f38ccc4f4b88afa42aed', - '7b4375d65f3f4524a79cb5f0', - 'd2ce098360ce4d19b6961017', - '847f5ee8d2df49a0ba1fd8a7', - '6164a71770c745ea8142a37c', - 'e98a0f1e15c846ac9b113608', - '5297d7df09b44d088cf80da5', - '62260b3f62ba423aa5c1962c', - '21fffc89d1d54e0190819384', - ], - shareId: 'ee5ae35d-540d-4a01-a938-ee7ee97b15ce', - title: 'Test Shared Link 31', - updatedAt: '2024-02-26T03:37:24.862Z', - }, - { - conversationId: '1e502d46-c710-4848-9bf2-674c08e51d9c', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-09T08:37:01.082Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'adb4bfb7657d4d7d92e82edf', - '70bdd81466e0408399b415d3', - 'ef99511981dc4c3baa18d372', - ], - shareId: 'b4fd8b63-7265-4825-89a4-9cebcbaadeee', - title: 'Test Shared Link 32', - updatedAt: '2024-02-27T04:32:40.654Z', - }, - { - conversationId: 'd1a43c39-f05e-4c6e-a8c2-0fcca9cb8928', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-26T15:03:25.546Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '07c070ab8a7541fea96b131c', - 'eb89cc57bcbb47ecb497cd5f', - '651999e46e734837b24c2500', - '608f9fbbbbb645e6b32d7d46', - ], - shareId: '5a4cf7d0-0abb-48c1-8e70-9f4ee3220dc4', - title: 'Test Shared Link 33', - updatedAt: '2024-04-06T21:39:51.521Z', - }, - { - conversationId: 'e549be2b-2623-42a3-8315-a8e35a7776b3', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-23T21:40:32.151Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'aa4e0b65650544589afb5961', - '160841178c0944e88de83956', - '234ac16af26d48a7875ee643', - ], - shareId: 'b083f048-2803-407e-b54a-89261db87ade', - title: 'Test Shared Link 34', - updatedAt: '2024-03-14T12:16:32.984Z', - }, - { - conversationId: '39f415ea-48f2-4bb2-b6f8-c2cf2d5fe42a', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-08T19:02:27.141Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'c77e79bb92b64d36a72d5a4d', - 'ea236310a9ba4b27a2217f09', - 'b25c46f2d23542f6b9d94de9', - ], - shareId: 'a9871169-7012-4206-b35c-7d207309a0f5', - title: 'Test Shared Link 35', - updatedAt: '2024-04-21T04:00:58.151Z', - }, - { - conversationId: 'c0d00265-12c4-45d0-a8bd-95d6e1bda769', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-14T09:50:55.476Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '63cdf78acd0449cf90237b29', - 'b93d82d7612b49fc98f0c211', - 'e56afe7e6e1e478d858a96d0', - '09344c8d22e74ce9b1d615cc', - ], - shareId: 'aa1262ab-54c9-406a-a97f-e2636266cf3e', - title: 'Test Shared Link 36', - updatedAt: '2024-03-24T15:53:36.021Z', - }, - { - conversationId: '5114b13d-8050-4e29-a2fd-85c776aec055', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-20T20:39:54.322Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'd397dc5c136a4c7da44e2fb9', - 'a3cd29243629450b87852a85', - '9dd1e0e918844a37ba8dc955', - 'ec2a73f7efe344fe85709c22', - '4d4702651869476b8ae397fd', - '8447430fd4f34aab82921018', - '8d804ee086734d6192b59995', - '29d6ccba37234bb8bd280977', - '31ec4f8c28cc4c21828ecef8', - '8ea630045b5847ec92651f4a', - ], - shareId: '2021fcab-7000-4840-9a8c-f0a1cb1ce8fa', - title: 'Test Shared Link 37', - updatedAt: '2024-04-08T02:09:33.732Z', - }, - { - conversationId: 'afa796fe-c8c1-411d-98d1-a8c8c8550412', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-16T23:58:11.179Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '8f54ee5871494f1b9f13f314', - '7778849398db40eb950952fb', - '65977e5d9e12445cb1cd9a54', - '8dba76884b09490a91b1aff9', - '2f6cc465171742b8a529daa3', - '1775b24fe2e94cd89dd6164e', - '780d980e59274240837c0bff', - ], - shareId: '9bb78460-0a26-4df7-be54-99b904b8084a', - title: 'Test Shared Link 38', - updatedAt: '2024-04-22T00:33:47.525Z', - }, - { - conversationId: 'c70fc447-acfc-4b57-84aa-2d8abcc3c5a5', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-24T11:39:14.696Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'b659ff86f9284ae1a40bee94', - '35bce7b6b2124db491f116c4', - 'cf0bad6c2623413babb33e65', - '26c6ce4d46614c86941d5429', - 'fba6517fc3434c188d8e1471', - '3e37398cc2ea4e50920d6271', - 'fd8584b1cf8145c88697b89d', - '8e433df0ada34e2280d4bd91', - 'fc52f80a6df24df5baccb657', - '95cdf9b05b8f4a81a70a37e9', - ], - shareId: '0664b078-8f29-41ff-8c1c-85172c659195', - title: 'Test Shared Link 39', - updatedAt: '2024-03-29T10:22:50.815Z', - }, - { - conversationId: '32ccaa36-cc46-4c84-888d-a86bf9b1d79c', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-04T03:13:19.399Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '8a0cfa8f5e874cf089f91b2e', - 'e9a72907ac9b4e88a8cfa737', - 'aa328aaf978944e18727a967', - '8786577a76b24415920d87a0', - 'ee05127d35ec415a85554406', - ], - shareId: 'a0018d28-52a8-4d31-8884-037cf9037eb7', - title: 'Test Shared Link 40', - updatedAt: '2024-01-30T03:26:15.920Z', - }, - { - conversationId: '2d8f1f40-b0e8-4629-937a-dee5036cb0bb', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-18T15:32:59.697Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '64475ed4f6234326a1104ca2', - 'db0db3ee92e14afaba6db75b', - '1f28a30501a94e3d896c261b', - 'de2eb08823db401d8262d3f3', - '254c32efae97476b954d8dc4', - 'dda42e4e74144cb69e395392', - '85bfe89de9e643fb8d5fa8ff', - '2f52e060a8b645928d0bf594', - ], - shareId: '9740b59b-cd84-461d-9fd7-2e1903b844b2', - title: 'Test Shared Link 41', - updatedAt: '2024-04-23T15:48:54.690Z', - }, - { - conversationId: '5180353f-23a9-48af-8ed0-b05983ef87d1', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-15T10:45:51.373Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '012b97e2df45475b93ad1e37', - '23d5042117a142f5a12762d5', - '8eb8cbca953d4ec18108f6d8', - 'ba506914203442339cd81d25', - '88c3b40cd0ae43d2b670ee41', - '0dd8fe241f5c4ea88730652c', - '80e3d1d7c26c489c9c8741fe', - '317a47a138c6499db73679f0', - '6497260d6a174f799cb56fd5', - ], - shareId: 'a6eaf23e-6e99-4e96-8222-82149c48803b', - title: 'Test Shared Link 42', - updatedAt: '2024-02-24T12:08:27.344Z', - }, - { - conversationId: 'cf3f2919-1840-4f6a-b350-f73f02ba6e90', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-02-14T06:20:45.439Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'ba3b939f8a3443f99f37b296', - 'b2039c988b3841c6b4ccb436', - '89ea6e1d4b3f440bb867d740', - '270210838a724aeb87e9bbe9', - '02dd6b2f185247d9888d5be1', - '6458fe13ee1c470ba33fb931', - ], - shareId: '765042c0-144d-4f7b-9953-0553ed438717', - title: 'Test Shared Link 43', - updatedAt: '2024-04-11T05:23:05.750Z', - }, - { - conversationId: '8efb71ee-7984-409a-b27c-aeb2650d78ba', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-01-28T16:41:04.100Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'cc60d9e2cbb7494481138833', - '1fb8d220b888475ba6c59fd3', - '5fd97817ab25451bb7ac22f5', - '9e8f7765a1bc4ab495da9081', - '4d5997d3c8744aaeb8c96964', - 'd438acb0f7704201857d6916', - 'b5106745d89f4a3fada8cd11', - '3b41562ce727411a83f44cdf', - '627f8f77feb843848145fc5f', - '6bee635eb10443ae9eef20ab', - ], - shareId: 'ed0fe440-479d-4c79-a494-0f461612c474', - title: 'Test Shared Link 44', - updatedAt: '2024-04-15T12:41:00.324Z', - }, - { - conversationId: '7cdd42a6-67bb-48c8-b8c3-bb55cbaa3905', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-24T23:13:42.892Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a944f461ca094d1c80bea677', - 'bd4b516b51a84285846343b4', - '442f6b4c27f647199279e49c', - 'e672974b3cf74cd3b85537f9', - ], - shareId: '9439972e-226c-4386-910a-e629eb7019c3', - title: 'Test Shared Link 45', - updatedAt: '2024-01-17T07:42:21.103Z', - }, - { - conversationId: '595bab25-e5c1-4bd0-99c1-a099391adb87', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-13T05:58:33.171Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'c39942615fdf435cb22369b5', - '0ec24a7328424a78b7dcecaf', - '335373a769fd43a5833eac16', - '22905090a44f4bf8b6f415f8', - ], - shareId: '18501e23-3fc5-436d-a9aa-ccde7c5c9074', - title: 'Test Shared Link 46', - updatedAt: '2024-02-05T04:34:42.323Z', - }, - { - conversationId: '822a650b-2971-441a-9cb0-b2ecabf7b3ba', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-20T10:29:20.771Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'ed566d1ffd51494e9a069f32', - '3ca9a8bbfb7c43e49e4898d7', - '6534186966784f0fba45c1ab', - '8a9e394dda8542d4a4db1140', - '002d883a1c344de0beb794b3', - '61e9e872aa854288a4ac9694', - '11e465cb875746aaa5894327', - 'ead6b00c855f4907ac5070af', - ], - shareId: 'aaaf89e4-eb3d-45f8-9e24-f370d777d8f7', - title: 'Test Shared Link 47', - updatedAt: '2024-04-29T03:11:47.109Z', - }, - { - conversationId: 'ce68ce26-07fc-4448-9239-f1925cfaaa72', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-03-15T15:04:08.691Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - 'a1851d231ee748e59ed43494', - '363372c828d8443b81abffd4', - '0b2e97210bd14e229ddb6641', - ], - shareId: 'f4de7c43-c058-43f5-bdab-0854d939dfb9', - title: 'Test Shared Link 48', - updatedAt: '2024-03-05T11:43:00.177Z', - }, - { - conversationId: '3fafe417-b5f8-4cc8-ac8e-897ebef836bd', - user: '662dbe728ca96f444c6f69f4', - createdAt: '2024-04-20T05:34:57.880Z', - isAnonymous: true, - isPublic: true, - isVisible: true, - messages: [ - '876337c495ca40c080b65c1d', - 'b5e914ac15ff439a9836a9ea', - 'cb6379d0a9ad442291d78c14', - '529424b650a4478ba012cf40', - '99ff1ed49cb2483bbd970730', - '0f0e215e179f4cfba56c7b03', - '210940fbe4c745d183358ed1', - '99246c796c7a44c2ae85a549', - 'a2b967556867499eb437674a', - ], - shareId: '79ec3716-ea2e-4045-8a82-056d63ebc939', - title: 'Test Shared Link 49', - updatedAt: '2024-03-19T08:01:13.445Z', - }, - ], - pages: 49, - pageNumber: '1', - pageSize: 25, - }, - ], - pageParams: [null], -}; diff --git a/client/src/utils/sharedLink.spec.ts b/client/src/utils/sharedLink.spec.ts deleted file mode 100644 index 1ead5ee2bec..00000000000 --- a/client/src/utils/sharedLink.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { sharedLinkData } from './sharedLink.fakeData'; -import { addSharedLink, updateSharedLink, deleteSharedLink } from './sharedLink'; - -import type { TSharedLink, SharedLinkListData } from 'librechat-data-provider'; - -describe('Shared Link Utilities', () => { - describe('addSharedLink', () => { - it('adds a new shared link to the top of the list', () => { - const data = { pages: [{ sharedLinks: [] }] }; - const newSharedLink = { shareId: 'new', updatedAt: '2023-04-02T12:00:00Z' }; - const newData = addSharedLink( - data as unknown as SharedLinkListData, - newSharedLink as TSharedLink, - ); - expect(newData.pages[0].sharedLinks).toHaveLength(1); - expect(newData.pages[0].sharedLinks[0].shareId).toBe('new'); - }); - it('does not add a shared link but updates it if it already exists', () => { - const data = { - pages: [ - { - sharedLinks: [ - { shareId: '1', updatedAt: '2023-04-01T12:00:00Z' }, - { shareId: '2', updatedAt: '2023-04-01T13:00:00Z' }, - ], - }, - ], - }; - const newSharedLink = { shareId: '2', updatedAt: '2023-04-02T12:00:00Z' }; - const newData = addSharedLink( - data as unknown as SharedLinkListData, - newSharedLink as TSharedLink, - ); - expect(newData.pages[0].sharedLinks).toHaveLength(2); - expect(newData.pages[0].sharedLinks[0].shareId).toBe('2'); - }); - }); - - describe('updateSharedLink', () => { - it('updates an existing shared link and moves it to the top', () => { - const initialData = { - pages: [ - { - sharedLinks: [ - { shareId: '1', updatedAt: '2023-04-01T12:00:00Z' }, - { shareId: '2', updatedAt: '2023-04-01T13:00:00Z' }, - ], - }, - ], - }; - const updatedSharedLink = { shareId: '1', updatedAt: '2023-04-02T12:00:00Z' }; - const newData = updateSharedLink( - initialData as unknown as SharedLinkListData, - updatedSharedLink as TSharedLink, - ); - expect(newData.pages[0].sharedLinks).toHaveLength(2); - expect(newData.pages[0].sharedLinks[0].shareId).toBe('1'); - }); - it('does not update a shared link if it does not exist', () => { - const initialData = { - pages: [ - { - sharedLinks: [ - { shareId: '1', updatedAt: '2023-04-01T12:00:00Z' }, - { shareId: '2', updatedAt: '2023-04-01T13:00:00Z' }, - ], - }, - ], - }; - const updatedSharedLink = { shareId: '3', updatedAt: '2023-04-02T12:00:00Z' }; - const newData = updateSharedLink( - initialData as unknown as SharedLinkListData, - updatedSharedLink as TSharedLink, - ); - expect(newData.pages[0].sharedLinks).toHaveLength(2); - expect(newData.pages[0].sharedLinks[0].shareId).toBe('1'); - }); - }); - - describe('deleteSharedLink', () => { - it('removes a shared link by id', () => { - const initialData = { - pages: [ - { - sharedLinks: [ - { shareId: '1', updatedAt: '2023-04-01T12:00:00Z' }, - { shareId: '2', updatedAt: '2023-04-01T13:00:00Z' }, - ], - }, - ], - }; - const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '1'); - expect(newData.pages[0].sharedLinks).toHaveLength(1); - expect(newData.pages[0].sharedLinks[0].shareId).not.toBe('1'); - }); - - it('does not remove a shared link if it does not exist', () => { - const initialData = { - pages: [ - { - sharedLinks: [ - { shareId: '1', updatedAt: '2023-04-01T12:00:00Z' }, - { shareId: '2', updatedAt: '2023-04-01T13:00:00Z' }, - ], - }, - ], - }; - const newData = deleteSharedLink(initialData as unknown as SharedLinkListData, '3'); - expect(newData.pages[0].sharedLinks).toHaveLength(2); - }); - }); -}); - -describe('Shared Link Utilities with Fake Data', () => { - describe('addSharedLink', () => { - it('adds a new shared link to the existing fake data', () => { - const newSharedLink = { - shareId: 'new', - updatedAt: new Date().toISOString(), - } as TSharedLink; - const initialLength = sharedLinkData.pages[0].sharedLinks.length; - const newData = addSharedLink(sharedLinkData, newSharedLink); - expect(newData.pages[0].sharedLinks.length).toBe(initialLength + 1); - expect(newData.pages[0].sharedLinks[0].shareId).toBe('new'); - }); - }); - - describe('updateSharedLink', () => { - it('updates an existing shared link within fake data', () => { - const updatedSharedLink = { - ...sharedLinkData.pages[0].sharedLinks[0], - title: 'Updated Title', - }; - const newData = updateSharedLink(sharedLinkData, updatedSharedLink); - expect(newData.pages[0].sharedLinks[0].title).toBe('Updated Title'); - }); - }); - - describe('deleteSharedLink', () => { - it('removes a shared link by id from fake data', () => { - const shareIdToDelete = sharedLinkData.pages[0].sharedLinks[0].shareId as string; - const newData = deleteSharedLink(sharedLinkData, shareIdToDelete); - const deletedDataExists = newData.pages[0].sharedLinks.some( - (c) => c.shareId === shareIdToDelete, - ); - expect(deletedDataExists).toBe(false); - }); - }); -}); diff --git a/client/src/utils/sharedLink.ts b/client/src/utils/sharedLink.ts deleted file mode 100644 index 3d273cb7190..00000000000 --- a/client/src/utils/sharedLink.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { InfiniteCollections } from 'librechat-data-provider'; -import { SharedLinkListData, SharedLinkListResponse, TSharedLink } from 'librechat-data-provider'; -import { addData, deleteData, updateData } from './collection'; -import { InfiniteData } from '@tanstack/react-query'; - -export const addSharedLink = ( - data: InfiniteData<SharedLinkListResponse>, - newSharedLink: TSharedLink, -): SharedLinkListData => { - return addData<SharedLinkListResponse, TSharedLink>( - data, - InfiniteCollections.SHARED_LINKS, - newSharedLink, - (page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId), - ); -}; - -export const updateSharedLink = ( - data: InfiniteData<SharedLinkListResponse>, - newSharedLink: TSharedLink, -): SharedLinkListData => { - return updateData<SharedLinkListResponse, TSharedLink>( - data, - InfiniteCollections.SHARED_LINKS, - newSharedLink, - (page) => page.sharedLinks.findIndex((c) => c.shareId === newSharedLink.shareId), - ); -}; - -export const deleteSharedLink = (data: SharedLinkListData, shareId: string): SharedLinkListData => { - return deleteData<SharedLinkListResponse, SharedLinkListData>( - data, - InfiniteCollections.SHARED_LINKS, - (page) => page.sharedLinks.findIndex((c) => c.shareId === shareId), - ); -}; diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 17325a9cf4f..ac867e85686 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -29,6 +29,7 @@ module.exports = { }, }, animation: { + 'fade-in': 'fadeIn 0.5s ease-out forwards', 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', }, @@ -83,6 +84,7 @@ module.exports = { 'surface-tertiary-alt': 'var(--surface-tertiary-alt)', 'surface-dialog': 'var(--surface-dialog)', 'surface-submit': 'var(--surface-submit)', + 'surface-submit-hover': 'var(--surface-submit-hover)', 'border-light': 'var(--border-light)', 'border-medium': 'var(--border-medium)', 'border-medium-alt': 'var(--border-medium-alt)', diff --git a/package-lock.json b/package-lock.json index 9dbeef6966b..fbf287a3f66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -933,6 +933,7 @@ "lucide-react": "^0.394.0", "match-sorter": "^6.3.4", "msedge-tts": "^1.3.4", + "qrcode.react": "^4.2.0", "rc-input-number": "^7.4.2", "react": "^18.2.0", "react-avatar-editor": "^13.0.2", @@ -30206,6 +30207,14 @@ } ] }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 81dbf89ac1c..bf6b780bdc2 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -14,10 +14,20 @@ export const messages = (conversationId: string, messageId?: string) => const shareRoot = '/api/share'; export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`; -export const getSharedLinks = (pageNumber: string, isPublic: boolean) => - `${shareRoot}?pageNumber=${pageNumber}&isPublic=${isPublic}`; -export const createSharedLink = shareRoot; -export const updateSharedLink = shareRoot; +export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`; +export const getSharedLinks = ( + pageSize: number, + isPublic: boolean, + sortBy: 'title' | 'createdAt', + sortDirection: 'asc' | 'desc', + search?: string, + cursor?: string, +) => + `${shareRoot}?pageSize=${pageSize}&isPublic=${isPublic}&sortBy=${sortBy}&sortDirection=${sortDirection}${ + search ? `&search=${search}` : '' + }${cursor ? `&cursor=${cursor}` : ''}`; +export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`; +export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`; const keysEndpoint = '/api/keys'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index d060b919db6..9574e486703 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -41,27 +41,29 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes return request.get(endpoints.shareMessages(shareId)); } -export const listSharedLinks = ( - params?: q.SharedLinkListParams, +export const listSharedLinks = async ( + params: q.SharedLinksListParams, ): Promise<q.SharedLinksResponse> => { - const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided - const isPublic = params?.isPublic ?? true; // Default to true if not provided - return request.get(endpoints.getSharedLinks(pageNumber, isPublic)); + const { pageSize, isPublic, sortBy, sortDirection, search, cursor } = params; + + return request.get( + endpoints.getSharedLinks(pageSize, isPublic, sortBy, sortDirection, search, cursor), + ); }; -export function getSharedLink(shareId: string): Promise<t.TSharedLinkResponse> { - return request.get(endpoints.shareMessages(shareId)); +export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetResponse> { + return request.get(endpoints.getSharedLink(conversationId)); } -export function createSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> { - return request.post(endpoints.createSharedLink, payload); +export function createSharedLink(conversationId: string): Promise<t.TSharedLinkResponse> { + return request.post(endpoints.createSharedLink(conversationId)); } -export function updateSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> { - return request.patch(endpoints.updateSharedLink, payload); +export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> { + return request.patch(endpoints.updateSharedLink(shareId)); } -export function deleteSharedLink(shareId: string): Promise<t.TDeleteSharedLinkResponse> { +export function deleteSharedLink(shareId: string): Promise<m.TDeleteSharedLinkResponse> { return request.delete(endpoints.shareMessages(shareId)); } diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 9e4234a4dfc..86eab7ddc61 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -75,6 +75,29 @@ export const useGetSharedMessages = ( ); }; +export const useGetSharedLinkQuery = ( + conversationId: string, + config?: UseQueryOptions<t.TSharedLinkGetResponse>, +): QueryObserverResult<t.TSharedLinkGetResponse> => { + const queryClient = useQueryClient(); + return useQuery<t.TSharedLinkGetResponse>( + [QueryKeys.sharedLinks, conversationId], + () => dataService.getSharedLink(conversationId), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + onSuccess: (data) => { + queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], { + conversationId: data.conversationId, + shareId: data.shareId, + }); + }, + ...config, + }, + ); +}; + export const useGetUserBalance = ( config?: UseQueryOptions<string>, ): QueryObserverResult<string> => { @@ -306,15 +329,18 @@ export const useRegisterUserMutation = ( options?: m.RegistrationOptions, ): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => { const queryClient = useQueryClient(); - return useMutation((payload: t.TRegisterUser) => dataService.register(payload), { - ...options, - onSuccess: (...args) => { - queryClient.invalidateQueries([QueryKeys.user]); - if (options?.onSuccess) { - options.onSuccess(...args); - } + return useMutation<t.TRegisterUserResponse, t.TError, t.TRegisterUser>( + (payload: t.TRegisterUser) => dataService.register(payload), + { + ...options, + onSuccess: (...args) => { + queryClient.invalidateQueries([QueryKeys.user]); + if (options?.onSuccess) { + options.onSuccess(...args); + } + }, }, - }); + ); }; export const useRefreshTokenMutation = (): UseMutationResult< diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 33f209c06e8..42f26eeffab 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -719,13 +719,12 @@ export const tSharedLinkSchema = z.object({ conversationId: z.string(), shareId: z.string(), messages: z.array(z.string()), - isAnonymous: z.boolean(), isPublic: z.boolean(), - isVisible: z.boolean(), title: z.string(), createdAt: z.string(), updatedAt: z.string(), }); + export type TSharedLink = z.infer<typeof tSharedLinkSchema>; export const tConversationTagSchema = z.object({ diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 12e49ef718f..418d455a23f 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -170,15 +170,17 @@ export type TArchiveConversationResponse = TConversation; export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & { messages: TMessage[]; }; -export type TSharedLinkRequest = Partial< - Omit<TSharedLink, 'messages' | 'createdAt' | 'updatedAt'> -> & { - conversationId: string; -}; -export type TSharedLinkResponse = TSharedLink; -export type TSharedLinksResponse = TSharedLink[]; -export type TDeleteSharedLinkResponse = TSharedLink; +export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>; + +export type TUpdateShareLinkRequest = Pick<TSharedLink, 'shareId'>; + +export type TSharedLinkResponse = Pick<TSharedLink, 'shareId'> & + Pick<TConversation, 'conversationId'>; + +export type TSharedLinkGetResponse = TSharedLinkResponse & { + success: boolean; +}; // type for getting conversation tags export type TConversationTagsResponse = TConversationTag[]; @@ -203,12 +205,10 @@ export type TDuplicateConvoRequest = { conversationId?: string; }; -export type TDuplicateConvoResponse = - | { - conversation: TConversation; - messages: TMessage[]; - } - | undefined; +export type TDuplicateConvoResponse = { + conversation: TConversation; + messages: TMessage[]; +}; export type TForkConvoRequest = { messageId: string; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 54a1534ba08..74dd18bcf37 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -24,6 +24,12 @@ export type MutationOptions< onSuccess?: (data: Response, variables: Request, context?: Context) => void; onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>; onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void; + onSettled?: ( + data: Response | undefined, + error: Error | null, + variables: Request, + context?: Context, + ) => void; }; export type TGenTitleRequest = { @@ -186,7 +192,12 @@ export type ArchiveConvoOptions = MutationOptions< types.TArchiveConversationRequest >; -export type DeleteSharedLinkOptions = MutationOptions<types.TSharedLink, { shareId: string }>; +export type DeleteSharedLinkContext = { previousQueries?: Map<string, TDeleteSharedLinkResponse> }; +export type DeleteSharedLinkOptions = MutationOptions< + TDeleteSharedLinkResponse, + { shareId: string }, + DeleteSharedLinkContext +>; export type TUpdatePromptContext = | { @@ -298,3 +309,9 @@ export type ToolCallMutationOptions<T extends ToolId> = MutationOptions< ToolCallResponse, ToolParams<T> >; + +export type TDeleteSharedLinkResponse = { + success: boolean; + shareId: string; + message: string; +}; diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 74d6e718353..4487728bbc0 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -41,23 +41,34 @@ export type ConversationUpdater = ( export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & { messages: s.TMessage[]; }; -export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & { - isPublic?: boolean; -}; -export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & { - sharedLinks: s.TSharedLink[]; -}; +export interface SharedLinksListParams { + pageSize: number; + isPublic: boolean; + sortBy: 'title' | 'createdAt'; + sortDirection: 'asc' | 'desc'; + search?: string; + cursor?: string; +} -// Type for the response from the conversation list API -export type SharedLinkListResponse = { - sharedLinks: s.TSharedLink[]; - pageNumber: string; - pageSize: string | number; - pages: string | number; +export type SharedLinkItem = { + shareId: string; + title: string; + isPublic: boolean; + createdAt: Date; + conversationId: string; }; -export type SharedLinkListData = InfiniteData<SharedLinkListResponse>; +export interface SharedLinksResponse { + links: SharedLinkItem[]; + nextCursor: string | null; + hasNextPage: boolean; +} + +export interface SharedLinkQueryData { + pages: SharedLinksResponse[]; + pageParams: (string | null)[]; +} export type AllPromptGroupsFilterRequest = { category: string;