mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
🔗 feat: Enhance Share Functionality, Optimize DataTable & Fix Critical Bugs (#5220)
* 🔄 refactor: frontend and backend share link logic; feat: qrcode for share link; feat: refresh link * 🐛 fix: Conditionally render shared link and refactor share link creation logic * 🐛 fix: Correct conditional check for shareId in ShareButton component * 🔄 refactor: Update shared links API and data handling; improve query parameters and response structure * 🔄 refactor: Update shared links pagination and response structure; replace pageNumber with cursor for improved data fetching * 🔄 refactor: DataTable performance optimization * fix: delete shared link cache update * 🔄 refactor: Enhance shared links functionality; add conversationId to shared link model and update related components * 🔄 refactor: Add delete functionality to SharedLinkButton; integrate delete mutation and confirmation dialog * 🔄 feat: Add AnimatedSearchInput component with gradient animations and search functionality; update search handling in API and localization * 🔄 refactor: Improve SharedLinks component; enhance delete functionality and loading states, optimize AnimatedSearchInput, and refine DataTable scrolling behavior * fix: mutation type issues with deleted shared link mutation * fix: MutationOptions types * fix: Ensure only public shared links are retrieved in getSharedLink function * fix: `qrcode.react` install location * fix: ensure non-public shared links are not fetched when checking for existing shared links, and remove deprecated .exec() method for queries * fix: types and import order * refactor: cleanup share button UI logic, make more intuitive --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
460cde0c0b
commit
fa9e778399
55 changed files with 1779 additions and 1975 deletions
|
|
@ -1,82 +1,71 @@
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const { Constants } = require('librechat-data-provider');
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
const { Conversation } = require('~/models/Conversation');
|
||||||
const SharedLink = require('./schema/shareSchema');
|
const SharedLink = require('./schema/shareSchema');
|
||||||
const { getMessages } = require('./Message');
|
const { getMessages } = require('./Message');
|
||||||
const logger = require('~/config/winston');
|
const logger = require('~/config/winston');
|
||||||
|
|
||||||
/**
|
class ShareServiceError extends Error {
|
||||||
* Anonymizes a conversation ID
|
constructor(message, code) {
|
||||||
* @returns {string} The anonymized conversation ID
|
super(message);
|
||||||
*/
|
this.name = 'ShareServiceError';
|
||||||
function anonymizeConvoId() {
|
this.code = code;
|
||||||
return `convo_${nanoid()}`;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const memoizedAnonymizeId = (prefix) => {
|
||||||
* Anonymizes an assistant ID
|
const memo = new Map();
|
||||||
* @returns {string} The anonymized assistant ID
|
return (id) => {
|
||||||
*/
|
if (!memo.has(id)) {
|
||||||
function anonymizeAssistantId() {
|
memo.set(id, `${prefix}_${nanoid()}`);
|
||||||
return `a_${nanoid()}`;
|
}
|
||||||
}
|
return memo.get(id);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
const anonymizeConvoId = memoizedAnonymizeId('convo');
|
||||||
* Anonymizes a message ID
|
const anonymizeAssistantId = memoizedAnonymizeId('a');
|
||||||
* @param {string} id - The original message ID
|
const anonymizeMessageId = (id) =>
|
||||||
* @returns {string} The anonymized message ID
|
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
|
||||||
*/
|
|
||||||
function anonymizeMessageId(id) {
|
|
||||||
return id === Constants.NO_PARENT ? id : `msg_${nanoid()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Anonymizes a conversation object
|
|
||||||
* @param {object} conversation - The conversation object
|
|
||||||
* @returns {object} The anonymized conversation object
|
|
||||||
*/
|
|
||||||
function anonymizeConvo(conversation) {
|
function anonymizeConvo(conversation) {
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const newConvo = { ...conversation };
|
const newConvo = { ...conversation };
|
||||||
if (newConvo.assistant_id) {
|
if (newConvo.assistant_id) {
|
||||||
newConvo.assistant_id = anonymizeAssistantId();
|
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
|
||||||
}
|
}
|
||||||
return newConvo;
|
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) {
|
function anonymizeMessages(messages, newConvoId) {
|
||||||
|
if (!Array.isArray(messages)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const idMap = new Map();
|
const idMap = new Map();
|
||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
const newMessageId = anonymizeMessageId(message.messageId);
|
const newMessageId = anonymizeMessageId(message.messageId);
|
||||||
idMap.set(message.messageId, newMessageId);
|
idMap.set(message.messageId, newMessageId);
|
||||||
|
|
||||||
const anonymizedMessage = Object.assign(message, {
|
return {
|
||||||
|
...message,
|
||||||
messageId: newMessageId,
|
messageId: newMessageId,
|
||||||
parentMessageId:
|
parentMessageId:
|
||||||
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
|
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
|
||||||
conversationId: newConvoId,
|
conversationId: newConvoId,
|
||||||
});
|
model: message.model?.startsWith('asst_')
|
||||||
|
? anonymizeAssistantId(message.model)
|
||||||
if (anonymizedMessage.model && anonymizedMessage.model.startsWith('asst_')) {
|
: message.model,
|
||||||
anonymizedMessage.model = anonymizeAssistantId();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return anonymizedMessage;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
async function getSharedMessages(shareId) {
|
||||||
try {
|
try {
|
||||||
const share = await SharedLink.findOne({ shareId })
|
const share = await SharedLink.findOne({ shareId, isPublic: true })
|
||||||
.populate({
|
.populate({
|
||||||
path: 'messages',
|
path: 'messages',
|
||||||
select: '-_id -__v -user',
|
select: '-_id -__v -user',
|
||||||
|
|
@ -84,165 +73,264 @@ async function getSharedMessages(shareId) {
|
||||||
.select('-_id -__v -user')
|
.select('-_id -__v -user')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
if (!share || !share.conversationId || !share.isPublic) {
|
if (!share?.conversationId || !share.isPublic) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConvoId = anonymizeConvoId();
|
const newConvoId = anonymizeConvoId(share.conversationId);
|
||||||
return Object.assign(share, {
|
const result = {
|
||||||
|
...share,
|
||||||
conversationId: newConvoId,
|
conversationId: newConvoId,
|
||||||
messages: anonymizeMessages(share.messages, newConvoId),
|
messages: anonymizeMessages(share.messages, newConvoId),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getShare] Error getting share link', error);
|
logger.error('[getShare] Error getting share link', {
|
||||||
throw new Error('Error getting share link');
|
error: error.message,
|
||||||
|
shareId,
|
||||||
|
});
|
||||||
|
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
|
||||||
* 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 };
|
|
||||||
try {
|
try {
|
||||||
const [totalConvos, sharedLinks] = await Promise.all([
|
const query = { user, isPublic };
|
||||||
SharedLink.countDocuments(query),
|
|
||||||
SharedLink.find(query)
|
|
||||||
.sort({ updatedAt: -1 })
|
|
||||||
.skip((pageNumber - 1) * pageSize)
|
|
||||||
.limit(pageSize)
|
|
||||||
.select('-_id -__v -user')
|
|
||||||
.lean(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil((totalConvos || 1) / pageSize);
|
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);
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
sharedLinks,
|
links: links.map((link) => ({
|
||||||
pages: totalPages,
|
shareId: link.shareId,
|
||||||
pageNumber,
|
title: link?.title || 'Untitled',
|
||||||
pageSize,
|
isPublic: link.isPublic,
|
||||||
|
createdAt: link.createdAt,
|
||||||
|
conversationId: link.conversationId,
|
||||||
|
})),
|
||||||
|
nextCursor,
|
||||||
|
hasNextPage,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getShareByPage] Error getting shares', error);
|
logger.error('[getSharedLinks] Error getting shares', {
|
||||||
throw new Error('Error getting shares');
|
error: error.message,
|
||||||
}
|
user,
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 }) {
|
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
}
|
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
const newConvoId = anonymizeConvoId();
|
|
||||||
const sharedConvo = anonymizeConvo(newShare);
|
|
||||||
return Object.assign(sharedConvo, {
|
|
||||||
conversationId: newConvoId,
|
|
||||||
messages: anonymizeMessages(newShare.messages, newConvoId),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[createSharedLink] Error creating shared link', error);
|
|
||||||
throw new Error('Error creating shared link');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 }) {
|
|
||||||
try {
|
|
||||||
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
|
|
||||||
if (!share) {
|
|
||||||
return { message: 'Share not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = await getMessages({ conversationId });
|
|
||||||
const update = { ...shareData, messages, user };
|
|
||||||
const updatedShare = await SharedLink.findOneAndUpdate({ conversationId, user }, update, {
|
|
||||||
new: true,
|
|
||||||
upsert: false,
|
|
||||||
}).lean();
|
|
||||||
|
|
||||||
const newConvoId = anonymizeConvoId();
|
|
||||||
const sharedConvo = anonymizeConvo(updatedShare);
|
|
||||||
return Object.assign(sharedConvo, {
|
|
||||||
conversationId: newConvoId,
|
|
||||||
messages: anonymizeMessages(updatedShare.messages, newConvoId),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[updateSharedLink] Error updating shared link', error);
|
|
||||||
throw new Error('Error updating shared link');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
async function deleteAllSharedLinks(user) {
|
||||||
try {
|
try {
|
||||||
const result = await SharedLink.deleteMany({ user });
|
const result = await SharedLink.deleteMany({ user });
|
||||||
return {
|
return {
|
||||||
message: 'All shared links have been deleted successfully',
|
message: 'All shared links deleted successfully',
|
||||||
deletedCount: result.deletedCount,
|
deletedCount: result.deletedCount,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[deleteAllSharedLinks] Error deleting shared links', error);
|
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
|
||||||
throw new Error('Error deleting shared links');
|
error: error.message,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSharedLink(user, conversationId) {
|
||||||
|
if (!user || !conversationId) {
|
||||||
|
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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();
|
||||||
|
await SharedLink.create({
|
||||||
|
shareId,
|
||||||
|
conversationId,
|
||||||
|
messages: conversationMessages,
|
||||||
|
title,
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
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('[getSharedLink] Error getting shared link', {
|
||||||
|
error: error.message,
|
||||||
|
user,
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSharedLink(user, shareId) {
|
||||||
|
if (!user || !shareId) {
|
||||||
|
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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: error.message,
|
||||||
|
user,
|
||||||
|
shareId,
|
||||||
|
});
|
||||||
|
throw new ShareServiceError(
|
||||||
|
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
|
||||||
|
error.code || 'SHARE_UPDATE_ERROR',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSharedLink(user, shareId) {
|
||||||
|
if (!user || !shareId) {
|
||||||
|
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
shareId,
|
||||||
|
message: 'Share deleted successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[deleteSharedLink] Error deleting shared link', {
|
||||||
|
error: error.message,
|
||||||
|
user,
|
||||||
|
shareId,
|
||||||
|
});
|
||||||
|
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
SharedLink,
|
SharedLink,
|
||||||
|
getSharedLink,
|
||||||
getSharedLinks,
|
getSharedLinks,
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
updateSharedLink,
|
updateSharedLink,
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,6 @@ const shareSchema = mongoose.Schema(
|
||||||
index: true,
|
index: true,
|
||||||
},
|
},
|
||||||
isPublic: {
|
isPublic: {
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isVisible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isAnonymous: {
|
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
getSharedLink,
|
||||||
getSharedMessages,
|
getSharedMessages,
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
updateSharedLink,
|
updateSharedLink,
|
||||||
|
|
@ -45,29 +46,60 @@ if (allowSharedLinks) {
|
||||||
*/
|
*/
|
||||||
router.get('/', requireJwtAuth, async (req, res) => {
|
router.get('/', requireJwtAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let pageNumber = req.query.pageNumber || 1;
|
const params = {
|
||||||
pageNumber = parseInt(pageNumber, 10);
|
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) {
|
const result = await getSharedLinks(
|
||||||
return res.status(400).json({ error: 'Invalid page number' });
|
req.user.id,
|
||||||
}
|
params.pageParam,
|
||||||
|
params.pageSize,
|
||||||
|
params.isPublic,
|
||||||
|
params.sortBy,
|
||||||
|
params.sortDirection,
|
||||||
|
params.search,
|
||||||
|
);
|
||||||
|
|
||||||
let pageSize = req.query.pageSize || 25;
|
res.status(200).send({
|
||||||
pageSize = parseInt(pageSize, 10);
|
links: result.links,
|
||||||
|
nextCursor: result.nextCursor,
|
||||||
if (isNaN(pageSize) || pageSize < 1) {
|
hasNextPage: result.hasNextPage,
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: 'Error getting shared links' });
|
console.error('Error getting shared links:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Error getting shared links',
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', requireJwtAuth, async (req, res) => {
|
router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const created = await createSharedLink(req.user.id, req.body);
|
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 link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const created = await createSharedLink(req.user.id, req.params.conversationId);
|
||||||
if (created) {
|
if (created) {
|
||||||
res.status(200).json(created);
|
res.status(200).json(created);
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
const updated = await updateSharedLink(req.user.id, req.body);
|
const updatedShare = await updateSharedLink(req.user.id, req.params.shareId);
|
||||||
if (updated) {
|
if (updatedShare) {
|
||||||
res.status(200).json(updated);
|
res.status(200).json(updatedShare);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +125,15 @@ router.patch('/', requireJwtAuth, async (req, res) => {
|
||||||
|
|
||||||
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
|
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId });
|
const result = await deleteSharedLink(req.user.id, req.params.shareId);
|
||||||
if (deleted) {
|
|
||||||
res.status(200).json(deleted);
|
if (!result) {
|
||||||
} else {
|
return res.status(404).json({ message: 'Share not found' });
|
||||||
res.status(404).end();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: 'Error deleting shared link' });
|
return res.status(400).json({ message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@
|
||||||
"lucide-react": "^0.394.0",
|
"lucide-react": "^0.394.0",
|
||||||
"match-sorter": "^6.3.4",
|
"match-sorter": "^6.3.4",
|
||||||
"msedge-tts": "^1.3.4",
|
"msedge-tts": "^1.3.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-input-number": "^7.4.2",
|
"rc-input-number": "^7.4.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-avatar-editor": "^13.0.2",
|
"react-avatar-editor": "^13.0.2",
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,6 @@ function ConvoOptions({
|
||||||
/>
|
/>
|
||||||
{showShareDialog && (
|
{showShareDialog && (
|
||||||
<ShareButton
|
<ShareButton
|
||||||
title={title ?? ''}
|
|
||||||
conversationId={conversationId ?? ''}
|
conversationId={conversationId ?? ''}
|
||||||
open={showShareDialog}
|
open={showShareDialog}
|
||||||
onOpenChange={setShowShareDialog}
|
onOpenChange={setShowShareDialog}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,102 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { OGDialog } from '~/components/ui';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useToastContext } from '~/Providers';
|
import { Copy, CopyCheck } from 'lucide-react';
|
||||||
import type { TSharedLink } from 'librechat-data-provider';
|
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||||
import { useCreateSharedLinkMutation } from '~/data-provider';
|
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
|
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||||
|
import { Button, Spinner, OGDialog } from '~/components';
|
||||||
import SharedLinkButton from './SharedLinkButton';
|
import SharedLinkButton from './SharedLinkButton';
|
||||||
import { NotificationSeverity } from '~/common';
|
import { cn } from '~/utils';
|
||||||
import { Spinner } from '~/components/svg';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
|
|
||||||
export default function ShareButton({
|
export default function ShareButton({
|
||||||
conversationId,
|
conversationId,
|
||||||
title,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
title: string;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
|
onOpenChange: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
triggerRef?: React.RefObject<HTMLButtonElement>;
|
triggerRef?: React.RefObject<HTMLButtonElement>;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { showToast } = useToastContext();
|
const [showQR, setShowQR] = useState(false);
|
||||||
const { mutate, isLoading } = useCreateSharedLinkMutation();
|
const [sharedLink, setSharedLink] = useState('');
|
||||||
const [share, setShare] = useState<TSharedLink | null>(null);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
const [isUpdated, setIsUpdated] = useState(false);
|
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
|
||||||
const [isNewSharedLink, setIsNewSharedLink] = useState(false);
|
const copyLink = useCopyToClipboard({ text: sharedLink });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open && triggerRef && triggerRef.current) {
|
if (share?.shareId !== undefined) {
|
||||||
triggerRef.current.focus();
|
const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`;
|
||||||
|
setSharedLink(link);
|
||||||
}
|
}
|
||||||
}, [open, triggerRef]);
|
}, [share]);
|
||||||
|
|
||||||
useEffect(() => {
|
const button =
|
||||||
if (isLoading || share) {
|
isLoading === true ? null : (
|
||||||
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
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!conversationId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = share && (
|
|
||||||
<SharedLinkButton
|
<SharedLinkButton
|
||||||
share={share}
|
share={share}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
setShare={setShare}
|
setShareDialogOpen={onOpenChange}
|
||||||
isUpdated={isUpdated}
|
showQR={showQR}
|
||||||
setIsUpdated={setIsUpdated}
|
setShowQR={setShowQR}
|
||||||
|
setSharedLink={setSharedLink}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shareId = share?.shareId ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
|
||||||
{children}
|
{children}
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
buttons={buttons}
|
buttons={button}
|
||||||
showCloseButton={true}
|
showCloseButton={true}
|
||||||
showCancelButton={false}
|
showCancelButton={false}
|
||||||
title={localize('com_ui_share_link_to_chat')}
|
title={localize('com_ui_share_link_to_chat')}
|
||||||
className="max-w-[550px]"
|
className="max-w-[550px]"
|
||||||
main={
|
main={
|
||||||
<div>
|
<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" />;
|
return <Spinner className="m-auto h-14 animate-spin" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUpdated) {
|
return share?.success === true
|
||||||
return isNewSharedLink
|
|
||||||
? localize('com_ui_share_created_message')
|
|
||||||
: localize('com_ui_share_updated_message');
|
|
||||||
}
|
|
||||||
|
|
||||||
return share?.isPublic === true
|
|
||||||
? localize('com_ui_share_update_message')
|
? localize('com_ui_share_update_message')
|
||||||
: localize('com_ui_share_create_message');
|
: localize('com_ui_share_create_message');
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,38 @@
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import { QrCode, RotateCw, Trash2 } from 'lucide-react';
|
||||||
import { Copy, Link } from 'lucide-react';
|
import type { TSharedLinkGetResponse } from 'librechat-data-provider';
|
||||||
import type { TSharedLink } from 'librechat-data-provider';
|
import {
|
||||||
import { useUpdateSharedLinkMutation } from '~/data-provider';
|
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 { NotificationSeverity } from '~/common';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import { Spinner } from '~/components/svg';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function SharedLinkButton({
|
export default function SharedLinkButton({
|
||||||
conversationId,
|
|
||||||
share,
|
share,
|
||||||
setShare,
|
conversationId,
|
||||||
isUpdated,
|
setShareDialogOpen,
|
||||||
setIsUpdated,
|
showQR,
|
||||||
|
setShowQR,
|
||||||
|
setSharedLink,
|
||||||
}: {
|
}: {
|
||||||
|
share: TSharedLinkGetResponse | undefined;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
share: TSharedLink;
|
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setShare: (share: TSharedLink) => void;
|
showQR: boolean;
|
||||||
isUpdated: boolean;
|
setShowQR: (showQR: boolean) => void;
|
||||||
setIsUpdated: (isUpdated: boolean) => void;
|
setSharedLink: (sharedLink: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { showToast } = useToastContext();
|
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: () => {
|
onError: () => {
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_share_error'),
|
message: localize('com_ui_share_error'),
|
||||||
|
|
@ -35,92 +42,145 @@ export default function SharedLinkButton({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyLink = () => {
|
const { mutateAsync, isLoading: isUpdateLoading } = useUpdateSharedLinkMutation({
|
||||||
if (!share) {
|
onError: () => {
|
||||||
return;
|
showToast({
|
||||||
}
|
message: localize('com_ui_share_error'),
|
||||||
setIsCopying(true);
|
severity: NotificationSeverity.ERROR,
|
||||||
const sharedLink =
|
showIcon: true,
|
||||||
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId;
|
});
|
||||||
copy(sharedLink);
|
},
|
||||||
setTimeout(() => {
|
|
||||||
setIsCopying(false);
|
|
||||||
}, 1500);
|
|
||||||
};
|
|
||||||
const updateSharedLink = async () => {
|
|
||||||
if (!share) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await mutateAsync({
|
|
||||||
shareId: share.shareId,
|
|
||||||
conversationId: conversationId,
|
|
||||||
isPublic: true,
|
|
||||||
isVisible: true,
|
|
||||||
isAnonymous: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
const deleteMutation = useDeleteSharedLinkMutation({
|
||||||
setShare(result);
|
onSuccess: async () => {
|
||||||
setIsUpdated(true);
|
setShowDeleteDialog(false);
|
||||||
copyLink();
|
setShareDialogOpen(false);
|
||||||
}
|
|
||||||
};
|
|
||||||
const getHandler = () => {
|
|
||||||
if (isUpdated) {
|
|
||||||
return {
|
|
||||||
handler: () => {
|
|
||||||
copyLink();
|
|
||||||
},
|
},
|
||||||
label: (
|
onError: (error) => {
|
||||||
<>
|
console.error('Delete error:', error);
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
showToast({
|
||||||
{localize('com_ui_copy_link')}
|
message: localize('com_ui_share_delete_error'),
|
||||||
</>
|
severity: NotificationSeverity.ERROR,
|
||||||
),
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
if (share.isPublic) {
|
|
||||||
return {
|
|
||||||
handler: async () => {
|
|
||||||
await updateSharedLink();
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
label: (
|
const generateShareLink = useCallback((shareId: string) => {
|
||||||
<>
|
return `${window.location.protocol}//${window.location.host}/share/${shareId}`;
|
||||||
<Link className="mr-2 h-4 w-4" />
|
}, []);
|
||||||
{localize('com_ui_update_link')}
|
|
||||||
</>
|
const updateSharedLink = async () => {
|
||||||
),
|
if (!shareId) {
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
const updateShare = await mutateAsync({ shareId });
|
||||||
|
const newLink = generateShareLink(updateShare.shareId);
|
||||||
|
setSharedLink(newLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createShareLink = async () => {
|
||||||
|
const share = await mutate({ conversationId });
|
||||||
|
const newLink = generateShareLink(share.shareId);
|
||||||
|
setSharedLink(newLink);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!shareId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync({ shareId });
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_shared_link_delete_success'),
|
||||||
|
severity: NotificationSeverity.SUCCESS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete shared link:', error);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_share_delete_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
handler: updateSharedLink,
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<Link className="mr-2 h-4 w-4" />
|
|
||||||
{localize('com_ui_create_link')}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers = getHandler();
|
|
||||||
return (
|
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" />
|
<div className="flex gap-2">
|
||||||
{localize('com_ui_copied')}
|
{!shareId && (
|
||||||
</>
|
<Button disabled={isCreateLoading} variant="submit" onClick={createShareLink}>
|
||||||
|
{!isCreateLoading && localize('com_ui_create_link')}
|
||||||
|
{isCreateLoading && <Spinner className="size-4" />}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!isCopying && !isLoading && handlers.label}
|
{shareId && (
|
||||||
{!isCopying && isLoading && <Spinner className="h-4 w-4" />}
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_refresh_link')}
|
||||||
|
render={(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
onClick={() => updateSharedLink()}
|
||||||
|
variant="outline"
|
||||||
|
disabled={isUpdateLoading}
|
||||||
|
>
|
||||||
|
{isUpdateLoading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TooltipAnchor
|
||||||
|
description={showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr')}
|
||||||
|
render={(props) => (
|
||||||
|
<Button {...props} onClick={() => setShowQR(!showQR)} variant="outline">
|
||||||
|
<QrCode className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_delete')}
|
||||||
|
render={(props) => (
|
||||||
|
<Button {...props} onClick={() => setShowDeleteDialog(true)} variant="destructive">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OGDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<OGDialogTemplate
|
||||||
|
showCloseButton={false}
|
||||||
|
title={localize('com_ui_delete_shared_link')}
|
||||||
|
className="max-w-[450px]"
|
||||||
|
main={
|
||||||
|
<>
|
||||||
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
|
<div className="grid w-full items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="dialog-confirm-delete"
|
||||||
|
className="text-left text-sm font-medium"
|
||||||
|
>
|
||||||
|
{localize('com_ui_delete_confirm')} <strong>"{shareId}"</strong>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
selection={{
|
||||||
|
selectHandler: handleDelete,
|
||||||
|
selectClasses:
|
||||||
|
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
||||||
|
selectText: localize('com_ui_delete'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OGDialog>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Link as LinkIcon, TrashIcon } from 'lucide-react';
|
|
||||||
import type { SharedLinksResponse, TSharedLink } from 'librechat-data-provider';
|
|
||||||
import { useDeleteSharedLinkMutation, useSharedLinksInfiniteQuery } from '~/data-provider';
|
|
||||||
import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks';
|
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
|
||||||
import { NotificationSeverity } from '~/common';
|
|
||||||
import { useToastContext } from '~/Providers';
|
|
||||||
import { cn } from '~/utils';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Label,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
TooltipAnchor,
|
|
||||||
Skeleton,
|
|
||||||
Spinner,
|
|
||||||
OGDialog,
|
|
||||||
OGDialogTrigger,
|
|
||||||
} from '~/components';
|
|
||||||
|
|
||||||
function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const localize = useLocalize();
|
|
||||||
|
|
||||||
const { showToast } = useToastContext();
|
|
||||||
const mutation = useDeleteSharedLinkMutation({
|
|
||||||
onError: () => {
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_share_delete_error'),
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
});
|
|
||||||
setIsDeleting(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmDelete = async (shareId: TSharedLink['shareId']) => {
|
|
||||||
if (mutation.isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsDeleting(true);
|
|
||||||
await mutation.mutateAsync({ shareId });
|
|
||||||
setIsDeleting(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow className={(cn(isDeleting && 'opacity-50'), 'hover:bg-transparent')}>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
to={`/share/${sharedLink.shareId}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center text-blue-500 hover:underline"
|
|
||||||
>
|
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
|
||||||
{sharedLink.title}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(sharedLink.createdAt).toLocaleDateString('en-US', {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{sharedLink.conversationId && (
|
|
||||||
<OGDialog>
|
|
||||||
<OGDialogTrigger asChild>
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_ui_delete')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
aria-label="Delete shared link"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
></TooltipAnchor>
|
|
||||||
</OGDialogTrigger>
|
|
||||||
<OGDialogTemplate
|
|
||||||
showCloseButton={false}
|
|
||||||
title={localize('com_ui_delete_shared_link')}
|
|
||||||
className="max-w-[450px]"
|
|
||||||
main={
|
|
||||||
<>
|
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
|
||||||
<div className="grid w-full items-center gap-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="dialog-confirm-delete"
|
|
||||||
className="text-left text-sm font-medium"
|
|
||||||
>
|
|
||||||
{localize('com_ui_delete_confirm')} <strong>{sharedLink.title}</strong>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
selection={{
|
|
||||||
selectHandler: () => confirmDelete(sharedLink.shareId),
|
|
||||||
selectClasses:
|
|
||||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
|
|
||||||
selectText: localize('com_ui_delete'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</OGDialog>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShareLinkTable({ className }) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { isAuthenticated } = useAuthContext();
|
|
||||||
const [showLoading, setShowLoading] = useState(false);
|
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isError, isLoading } =
|
|
||||||
useSharedLinksInfiniteQuery({ pageNumber: '1', isPublic: true }, { enabled: isAuthenticated });
|
|
||||||
|
|
||||||
const { containerRef } = useNavScrolling<SharedLinksResponse>({
|
|
||||||
setShowLoading,
|
|
||||||
hasNextPage: hasNextPage,
|
|
||||||
fetchNextPage: fetchNextPage,
|
|
||||||
isFetchingNextPage: isFetchingNextPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sharedLinks = useMemo(() => data?.pages.flatMap((page) => page.sharedLinks) || [], [data]);
|
|
||||||
|
|
||||||
const getRandomWidth = () => Math.floor(Math.random() * (400 - 170 + 1)) + 170;
|
|
||||||
|
|
||||||
const skeletons = Array.from({ length: 11 }, (_, index) => {
|
|
||||||
const randomWidth = getRandomWidth();
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex h-10 w-full items-center">
|
|
||||||
<div className="flex w-[410px] items-center">
|
|
||||||
<Skeleton className="h-4" style={{ width: `${randomWidth}px` }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-grow justify-center">
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
</div>
|
|
||||||
<div className="mr-2 flex justify-end">
|
|
||||||
<Skeleton className="h-4 w-12" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="text-gray-300">{skeletons}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-200">
|
|
||||||
{localize('com_ui_share_retrieve_error')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedLinks.length === 0) {
|
|
||||||
return <div className="text-gray-300">{localize('com_nav_shared_links_empty')}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'-mr-2 grid max-h-[350px] w-full flex-1 flex-col gap-2 overflow-y-auto pr-2 transition-opacity duration-500',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>{localize('com_nav_shared_links_name')}</TableHead>
|
|
||||||
<TableHead>{localize('com_nav_shared_links_date_shared')}</TableHead>
|
|
||||||
<TableHead className="text-right">{localize('com_assistants_actions')}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{sharedLinks.map((sharedLink) => (
|
|
||||||
<ShareLinkRow key={sharedLink.shareId} sharedLink={sharedLink} />
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
{(isFetchingNextPage || showLoading) && <Spinner className="mx-auto my-4" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,324 @@
|
||||||
import { useLocalize } from '~/hooks';
|
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||||
import { OGDialog, OGDialogTrigger } from '~/components/ui';
|
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 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() {
|
export default function SharedLinks() {
|
||||||
const localize = useLocalize();
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_shared_links')}</div>
|
<div>{localize('com_nav_shared_links')}</div>
|
||||||
|
|
||||||
<OGDialog>
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||||
<button className="btn btn-neutral relative ">
|
<button className="btn btn-neutral relative">
|
||||||
{localize('com_nav_shared_links_manage')}
|
{localize('com_nav_shared_links_manage')}
|
||||||
</button>
|
</button>
|
||||||
</OGDialogTrigger>
|
</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
|
<OGDialogTemplate
|
||||||
title={localize('com_nav_shared_links')}
|
showCloseButton={false}
|
||||||
className="max-w-[1000px]"
|
title={localize('com_ui_delete_shared_link')}
|
||||||
showCancelButton={false}
|
className="max-w-[450px]"
|
||||||
main={<ShareLinkTable className="w-full" />}
|
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>
|
</OGDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -140,9 +140,9 @@ const AdminSettings = () => {
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size={'sm'}
|
size='sm'
|
||||||
variant={'outline'}
|
variant='outline'
|
||||||
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent"
|
className="h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary"
|
||||||
>
|
>
|
||||||
<ShieldEllipsis className="cursor-pointer" />
|
<ShieldEllipsis className="cursor-pointer" />
|
||||||
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
import { Button, OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
import { TrashIcon } from '~/components/svg';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
const DeleteVersion = ({
|
const DeleteVersion = ({
|
||||||
|
|
@ -18,14 +18,15 @@ const DeleteVersion = ({
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size={'sm'}
|
variant="default"
|
||||||
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"
|
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}
|
disabled={disabled}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-lg cursor-pointer text-white dark:text-white" />
|
<Trash2 className="cursor-pointer text-white size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
|
|
|
||||||
|
|
@ -256,9 +256,9 @@ const PromptForm = () => {
|
||||||
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
||||||
{editorMode === PromptsEditorMode.ADVANCED && (
|
{editorMode === PromptsEditorMode.ADVANCED && (
|
||||||
<Button
|
<Button
|
||||||
size={'sm'}
|
variant="default"
|
||||||
className="h-10 border border-transparent bg-green-500 transition-all hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600"
|
size="sm"
|
||||||
variant={'default'}
|
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={() => {
|
onClick={() => {
|
||||||
const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt);
|
const { _id: promptVersionId = '', prompt } = selectedPrompt ?? ({} as TPrompt);
|
||||||
makeProductionMutation.mutate(
|
makeProductionMutation.mutate(
|
||||||
|
|
@ -283,7 +283,7 @@ const PromptForm = () => {
|
||||||
makeProductionMutation.isLoading
|
makeProductionMutation.isLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Rocket className="cursor-pointer text-white" />
|
<Rocket className="cursor-pointer text-white size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
|
|
|
||||||
|
|
@ -80,16 +80,18 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={'default'}
|
variant="default"
|
||||||
size={'sm'}
|
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"
|
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}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Share2Icon className="cursor-pointer text-white " />
|
<Share2Icon className="size-5 cursor-pointer text-white" />
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogContent className="border-border-light bg-surface-primary-alt text-text-secondary">
|
<OGDialogContent className="w-11/12 max-w-[600px]">
|
||||||
<OGDialogTitle>{localize('com_ui_share_var', `"${group.name}"`)}</OGDialogTitle>
|
<OGDialogTitle className="truncate pr-2" title={group.name}>
|
||||||
|
{localize('com_ui_share_var', `"${group.name}"`)}
|
||||||
|
</OGDialogTitle>
|
||||||
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
<form className="p-2" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="mb-4 flex items-center justify-between gap-2 py-4">
|
<div className="mb-4 flex items-center justify-between gap-2 py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
|
||||||
106
client/src/components/ui/AnimatedSearchInput.tsx
Normal file
106
client/src/components/ui/AnimatedSearchInput.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
const AnimatedSearchInput = ({ value, onChange, isSearching: searching, placeholder }) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const isSearching = searching === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="relative rounded-lg transition-all duration-500 ease-in-out">
|
||||||
|
{/* Background gradient effect */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute inset-0 rounded-lg
|
||||||
|
bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-blue-500/20
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
${isSearching ? 'opacity-100 blur-sm' : 'opacity-0 blur-none'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||||
|
<Search
|
||||||
|
className={`
|
||||||
|
h-4 w-4 transition-all duration-500 ease-in-out
|
||||||
|
${isFocused ? 'text-blue-500' : 'text-gray-400'}
|
||||||
|
${isSearching ? 'text-blue-400' : ''}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input field with background transitions */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`
|
||||||
|
w-full rounded-lg px-10 py-2
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
placeholder:text-gray-400
|
||||||
|
focus:outline-none focus:ring-0
|
||||||
|
${isFocused ? 'bg-white/10' : 'bg-white/5'}
|
||||||
|
${isSearching ? 'bg-white/15' : ''}
|
||||||
|
backdrop-blur-sm
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated loading indicator */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute right-3 top-1/2 -translate-y-1/2
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
${isSearching ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="relative h-2 w-2">
|
||||||
|
<div className="absolute inset-0 animate-ping rounded-full bg-blue-500/60" />
|
||||||
|
<div className="absolute inset-0 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outer glow effect */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute -inset-8 -z-10
|
||||||
|
transition-all duration-700 ease-in-out
|
||||||
|
${isSearching ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-gradient-radial absolute inset-0 from-blue-500/10 to-transparent
|
||||||
|
transition-opacity duration-700 ease-in-out
|
||||||
|
${isSearching ? 'animate-pulse-slow opacity-100' : 'opacity-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute inset-0 bg-gradient-to-r from-purple-500/5 via-blue-500/5 to-purple-500/5
|
||||||
|
blur-xl transition-all duration-700 ease-in-out
|
||||||
|
${isSearching ? 'animate-gradient-x opacity-100' : 'opacity-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Focus state background glow */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute inset-0 -z-20 bg-gradient-to-r from-blue-500/10
|
||||||
|
via-purple-500/10 to-blue-500/10 blur-xl
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
${isFocused ? 'scale-105 opacity-100' : 'scale-100 opacity-0'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnimatedSearchInput;
|
||||||
|
|
@ -15,7 +15,8 @@ const buttonVariants = cva(
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
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: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-4 py-2',
|
||||||
|
|
|
||||||
459
client/src/components/ui/DataTable.tsx
Normal file
459
client/src/components/ui/DataTable.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
SortingState,
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
VisibilityState,
|
||||||
|
getSortedRowModel,
|
||||||
|
ColumnFiltersState,
|
||||||
|
getFilteredRowModel,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import type { Table as TTable } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Checkbox,
|
||||||
|
TableRow,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
AnimatedSearchInput,
|
||||||
|
} from './';
|
||||||
|
import { TrashIcon, Spinner } from '~/components/svg';
|
||||||
|
import { useLocalize, useMediaQuery } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||||
|
meta?: {
|
||||||
|
size?: string | number;
|
||||||
|
mobileSize?: string | number;
|
||||||
|
minWidth?: string | number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectionCheckbox = memo(
|
||||||
|
({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex h-full w-[30px] items-center justify-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||||
|
|
||||||
|
interface DataTableProps<TData, TValue> {
|
||||||
|
columns: TableColumn<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||||
|
filterColumn?: string;
|
||||||
|
defaultSort?: SortingState;
|
||||||
|
columnVisibilityMap?: Record<string, string>;
|
||||||
|
className?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||||
|
enableRowSelection?: boolean;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
onFilterChange?: (value: string) => void;
|
||||||
|
filterValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowComponent = <TData, TValue>({
|
||||||
|
row,
|
||||||
|
isSmallScreen,
|
||||||
|
onSelectionChange,
|
||||||
|
index,
|
||||||
|
isSearching,
|
||||||
|
}: {
|
||||||
|
row: Row<TData>;
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||||
|
index: number;
|
||||||
|
isSearching: boolean;
|
||||||
|
}) => {
|
||||||
|
const handleSelection = useCallback(
|
||||||
|
(value: boolean) => {
|
||||||
|
row.toggleSelected(value);
|
||||||
|
onSelectionChange?.(row.id, value);
|
||||||
|
},
|
||||||
|
[row, onSelectionChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||||
|
className={`
|
||||||
|
motion-safe:animate-fadeIn border-b
|
||||||
|
border-border-light transition-all duration-300
|
||||||
|
ease-out
|
||||||
|
hover:bg-surface-secondary
|
||||||
|
${isSearching ? 'opacity-50' : 'opacity-100'}
|
||||||
|
${isSearching ? 'scale-98' : 'scale-100'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 20}ms`,
|
||||||
|
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
if (cell.column.id === 'select') {
|
||||||
|
return (
|
||||||
|
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={handleSelection}
|
||||||
|
ariaLabel="Select row"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className={`
|
||||||
|
w-0 max-w-0 px-2 py-1 align-middle text-xs
|
||||||
|
transition-all duration-300 sm:px-4
|
||||||
|
sm:py-2 sm:text-sm
|
||||||
|
${isSearching ? 'blur-[0.3px]' : 'blur-0'}
|
||||||
|
`}
|
||||||
|
style={getColumnStyle(
|
||||||
|
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||||
|
isSmallScreen,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden text-ellipsis">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||||
|
|
||||||
|
function getColumnStyle<TData, TValue>(
|
||||||
|
column: TableColumn<TData, TValue>,
|
||||||
|
isSmallScreen: boolean,
|
||||||
|
): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||||
|
minWidth: column.meta?.minWidth,
|
||||||
|
maxWidth: column.meta?.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteButton = memo(
|
||||||
|
({
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
disabled,
|
||||||
|
isSmallScreen,
|
||||||
|
localize,
|
||||||
|
}: {
|
||||||
|
onDelete?: () => Promise<void>;
|
||||||
|
isDeleting: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
localize: (key: string) => string;
|
||||||
|
}) => {
|
||||||
|
if (!onDelete) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||||
|
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DataTable<TData, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
onDelete,
|
||||||
|
filterColumn,
|
||||||
|
defaultSort = [],
|
||||||
|
className = '',
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
hasNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
enableRowSelection = true,
|
||||||
|
showCheckboxes = true,
|
||||||
|
onFilterChange,
|
||||||
|
filterValue,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const tableColumns = useMemo(() => {
|
||||||
|
if (!enableRowSelection || !showCheckboxes) {
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
const selectColumn = {
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }: { table: TTable<TData> }) => (
|
||||||
|
<div className="flex h-full w-[30px] items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: Row<TData> }) => (
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={(value) => row.toggleSelected(value)}
|
||||||
|
ariaLabel="Select row"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { size: '50px' },
|
||||||
|
};
|
||||||
|
return [selectColumn, ...columns];
|
||||||
|
}, [columns, enableRowSelection, showCheckboxes]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: tableColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
enableRowSelection,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = table.getRowModel();
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => tableContainerRef.current,
|
||||||
|
estimateSize: useCallback(() => 48, []),
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = tableContainerRef.current;
|
||||||
|
if (!scrollElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = async () => {
|
||||||
|
if (!hasNextPage || isFetchingNextPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||||
|
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||||
|
try {
|
||||||
|
// Safely fetch next page without breaking if lastPage is undefined
|
||||||
|
await fetchNextPage?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to fetch next page:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSearching(true);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onFilterChange?.(searchTerm);
|
||||||
|
setIsSearching(false);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [searchTerm, onFilterChange]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!onDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||||
|
await onDelete(itemsToDelete);
|
||||||
|
setRowSelection({});
|
||||||
|
// await fetchNextPage?.({ pageParam: lastPage?.nextCursor });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}, [onDelete, table]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||||
|
{/* Table controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 py-2 sm:gap-4 sm:py-4">
|
||||||
|
{enableRowSelection && showCheckboxes && (
|
||||||
|
<DeleteButton
|
||||||
|
onDelete={handleDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
localize={localize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filterColumn !== undefined && table.getColumn(filterColumn) && (
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<AnimatedSearchInput
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
isSearching={isSearching}
|
||||||
|
placeholder={`${localize('com_ui_search')}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Virtualized table */}
|
||||||
|
<div
|
||||||
|
ref={tableContainerRef}
|
||||||
|
className={cn(
|
||||||
|
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||||
|
'transition-all duration-300 ease-out',
|
||||||
|
isSearching && 'bg-surface-secondary/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||||
|
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||||
|
style={getColumnStyle(
|
||||||
|
header.column.columnDef as TableColumn<TData, TValue>,
|
||||||
|
isSmallScreen,
|
||||||
|
)}
|
||||||
|
onClick={
|
||||||
|
header.column.getCanSort()
|
||||||
|
? header.column.getToggleSortingHandler()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{paddingTop > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td style={{ height: `${paddingTop}px` }} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<MemoizedTableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
index={virtualRow.index}
|
||||||
|
isSearching={isSearching}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!virtualRows.length && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||||
|
{localize('com_ui_no_data')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paddingBottom > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td style={{ height: `${paddingBottom}px` }} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{(isFetchingNextPage || hasNextPage) && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={columns.length} className="p-4">
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
hasNextPage && <div className="h-6" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
OGDialogHeader,
|
OGDialogHeader,
|
||||||
OGDialogContent,
|
OGDialogContent,
|
||||||
OGDialogDescription,
|
OGDialogDescription,
|
||||||
} from './';
|
} from './OriginalDialog';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils/';
|
import { cn } from '~/utils/';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export { default as ThemeSelector } from './ThemeSelector';
|
||||||
export { default as SelectDropDown } from './SelectDropDown';
|
export { default as SelectDropDown } from './SelectDropDown';
|
||||||
export { default as MultiSelectPop } from './MultiSelectPop';
|
export { default as MultiSelectPop } from './MultiSelectPop';
|
||||||
export { default as ModelParameters } from './ModelParameters';
|
export { default as ModelParameters } from './ModelParameters';
|
||||||
|
export { default as OGDialogTemplate } from './OGDialogTemplate';
|
||||||
export { default as InputWithDropdown } from './InputWithDropDown';
|
export { default as InputWithDropdown } from './InputWithDropDown';
|
||||||
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
export { default as SelectDropDownPop } from './SelectDropDownPop';
|
||||||
|
export { default as AnimatedSearchInput } from './AnimatedSearchInput';
|
||||||
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
export { default as MultiSelectDropDown } from './MultiSelectDropDown';
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,18 @@
|
||||||
import {
|
import {
|
||||||
Constants,
|
Constants,
|
||||||
InfiniteCollections,
|
|
||||||
defaultAssistantsVersion,
|
defaultAssistantsVersion,
|
||||||
ConversationListResponse,
|
ConversationListResponse,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { dataService, MutationKeys, QueryKeys, defaultOrderQuery } from 'librechat-data-provider';
|
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 { 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 useUpdateTagsInConvo from '~/hooks/Conversations/useUpdateTagsInConvo';
|
||||||
import { updateConversationTag } from '~/utils/conversationTags';
|
import { updateConversationTag } from '~/utils/conversationTags';
|
||||||
import { normalizeData } from '~/utils/collection';
|
import { normalizeData } from '~/utils/collection';
|
||||||
import {
|
|
||||||
useConversationTagsQuery,
|
|
||||||
useConversationsInfiniteQuery,
|
|
||||||
useSharedLinksInfiniteQuery,
|
|
||||||
} from './queries';
|
|
||||||
import {
|
import {
|
||||||
logger,
|
logger,
|
||||||
/* Shared Links */
|
|
||||||
addSharedLink,
|
|
||||||
deleteSharedLink,
|
|
||||||
/* Conversations */
|
/* Conversations */
|
||||||
addConversation,
|
addConversation,
|
||||||
updateConvoFields,
|
updateConvoFields,
|
||||||
|
|
@ -244,120 +236,126 @@ export const useArchiveConvoMutation = (options?: t.ArchiveConvoOptions) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateSharedLinkMutation = (
|
export const useCreateSharedLinkMutation = (
|
||||||
options?: t.CreateSharedLinkOptions,
|
options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>,
|
||||||
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {
|
): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { refetch } = useSharedLinksInfiniteQuery();
|
|
||||||
const { onSuccess, ..._options } = options || {};
|
const { onSuccess, ..._options } = options || {};
|
||||||
return useMutation((payload: t.TSharedLinkRequest) => dataService.createSharedLink(payload), {
|
return useMutation(
|
||||||
onSuccess: (_data, vars, context) => {
|
({ conversationId }: { conversationId: string }) => {
|
||||||
if (!vars.conversationId) {
|
if (!conversationId) {
|
||||||
return;
|
throw new Error('Conversation ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPublic = vars.isPublic === true;
|
return dataService.createSharedLink(conversationId);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||||
|
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = (
|
export const useUpdateSharedLinkMutation = (
|
||||||
options?: t.UpdateSharedLinkOptions,
|
options?: t.MutationOptions<t.TUpdateShareLinkRequest, { shareId: string }>,
|
||||||
): UseMutationResult<t.TSharedLinkResponse, unknown, t.TSharedLinkRequest, unknown> => {
|
): UseMutationResult<t.TSharedLinkResponse, unknown, { shareId: string }, unknown> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { refetch } = useSharedLinksInfiniteQuery();
|
|
||||||
const { onSuccess, ..._options } = options || {};
|
const { onSuccess, ..._options } = options || {};
|
||||||
return useMutation((payload: t.TSharedLinkRequest) => dataService.updateSharedLink(payload), {
|
return useMutation(
|
||||||
onSuccess: (_data, vars, context) => {
|
({ shareId }) => {
|
||||||
if (!vars.conversationId) {
|
if (!shareId) {
|
||||||
return;
|
throw new Error('Share ID is required');
|
||||||
}
|
|
||||||
|
|
||||||
const isPublic = vars.isPublic === true;
|
|
||||||
|
|
||||||
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (sharedLink) => {
|
|
||||||
if (!sharedLink) {
|
|
||||||
return sharedLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return dataService.updateSharedLink(shareId);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||||
|
queryClient.setQueryData([QueryKeys.sharedLinks, _data.conversationId], _data);
|
||||||
|
|
||||||
onSuccess?.(_data, vars, context);
|
onSuccess?.(_data, vars, context);
|
||||||
},
|
},
|
||||||
..._options,
|
..._options,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDeleteSharedLinkMutation = (
|
export const useDeleteSharedLinkMutation = (
|
||||||
options?: t.DeleteSharedLinkOptions,
|
options?: t.DeleteSharedLinkOptions,
|
||||||
): UseMutationResult<t.TDeleteSharedLinkResponse, unknown, { shareId: string }, unknown> => {
|
): UseMutationResult<
|
||||||
|
t.TDeleteSharedLinkResponse,
|
||||||
|
unknown,
|
||||||
|
{ shareId: string },
|
||||||
|
t.DeleteSharedLinkContext
|
||||||
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { refetch } = useSharedLinksInfiniteQuery();
|
const { onSuccess } = options || {};
|
||||||
const { onSuccess, ..._options } = options || {};
|
|
||||||
return useMutation(({ shareId }) => dataService.deleteSharedLink(shareId), {
|
return useMutation((vars) => dataService.deleteSharedLink(vars.shareId), {
|
||||||
onSuccess: (_data, vars, context) => {
|
onMutate: async (vars) => {
|
||||||
if (!vars.shareId) {
|
await queryClient.cancelQueries({
|
||||||
return;
|
queryKey: [QueryKeys.sharedLinks],
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData([QueryKeys.sharedMessages, vars.shareId], null);
|
const updatedPages = old.pages.map((page) => ({
|
||||||
queryClient.setQueryData<t.SharedLinkListData>([QueryKeys.sharedLinks], (data) => {
|
...page,
|
||||||
if (!data) {
|
links: page.links.filter((link) => link.shareId !== vars.shareId),
|
||||||
return data;
|
}));
|
||||||
}
|
|
||||||
return normalizeData(
|
const nonEmptyPages = updatedPages.filter((page) => page.links.length > 0);
|
||||||
deleteSharedLink(data, vars.shareId),
|
|
||||||
InfiniteCollections.SHARED_LINKS,
|
return {
|
||||||
data.pages[0].pageSize as number,
|
...old,
|
||||||
);
|
pages: nonEmptyPages,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const current = queryClient.getQueryData<t.ConversationData>([QueryKeys.sharedLinks]);
|
|
||||||
refetch({
|
|
||||||
refetchPage: (page, index) => index === (current?.pages.length ?? 1) - 1,
|
|
||||||
});
|
});
|
||||||
onSuccess?.(_data, vars, context);
|
|
||||||
|
return { previousQueries };
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (context?.previousQueries) {
|
||||||
|
context.previousQueries.forEach((prevData: unknown, prevQueryKey: unknown) => {
|
||||||
|
queryClient.setQueryData(prevQueryKey as string[], prevData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QueryKeys.sharedLinks],
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(data, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: [QueryKeys.sharedLinks],
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
..._options,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -575,9 +573,7 @@ export const useDuplicateConversationMutation = (
|
||||||
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
|
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { onSuccess, ..._options } = options ?? {};
|
const { onSuccess, ..._options } = options ?? {};
|
||||||
return useMutation(
|
return useMutation((payload) => dataService.duplicateConversation(payload), {
|
||||||
(payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload),
|
|
||||||
{
|
|
||||||
onSuccess: (data, vars, context) => {
|
onSuccess: (data, vars, context) => {
|
||||||
const originalId = vars.conversationId ?? '';
|
const originalId = vars.conversationId ?? '';
|
||||||
if (originalId.length === 0) {
|
if (originalId.length === 0) {
|
||||||
|
|
@ -603,8 +599,7 @@ export const useDuplicateConversationMutation = (
|
||||||
onSuccess?.(data, vars, context);
|
onSuccess?.(data, vars, context);
|
||||||
},
|
},
|
||||||
..._options,
|
..._options,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useForkConvoMutation = (
|
export const useForkConvoMutation = (
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import type {
|
||||||
AssistantDocument,
|
AssistantDocument,
|
||||||
TEndpointsConfig,
|
TEndpointsConfig,
|
||||||
TCheckUserKeyResponse,
|
TCheckUserKeyResponse,
|
||||||
SharedLinkListParams,
|
SharedLinksListParams,
|
||||||
SharedLinksResponse,
|
SharedLinksResponse,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { findPageForConversation } from '~/utils';
|
import { findPageForConversation } from '~/utils';
|
||||||
|
|
@ -139,31 +139,29 @@ export const useConversationsInfiniteQuery = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSharedLinksInfiniteQuery = (
|
export const useSharedLinksQuery = (
|
||||||
params?: SharedLinkListParams,
|
params: SharedLinksListParams,
|
||||||
config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>,
|
config?: UseInfiniteQueryOptions<SharedLinksResponse, unknown>,
|
||||||
) => {
|
) => {
|
||||||
return useInfiniteQuery<SharedLinksResponse, unknown>(
|
const { pageSize, isPublic, search, sortBy, sortDirection } = params;
|
||||||
[QueryKeys.sharedLinks],
|
|
||||||
({ pageParam = '' }) =>
|
return useInfiniteQuery<SharedLinksResponse>({
|
||||||
|
queryKey: [QueryKeys.sharedLinks, { pageSize, isPublic, search, sortBy, sortDirection }],
|
||||||
|
queryFn: ({ pageParam }) =>
|
||||||
dataService.listSharedLinks({
|
dataService.listSharedLinks({
|
||||||
...params,
|
cursor: pageParam?.toString(),
|
||||||
pageNumber: pageParam?.toString(),
|
pageSize,
|
||||||
isPublic: params?.isPublic || true,
|
isPublic,
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
}),
|
}),
|
||||||
{
|
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
|
||||||
getNextPageParam: (lastPage) => {
|
keepPreviousData: true,
|
||||||
const currentPageNumber = Number(lastPage.pageNumber);
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
const totalPages = Number(lastPage.pages); // Convert totalPages to a number
|
cacheTime: 30 * 60 * 1000, // 30 minutes
|
||||||
// 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,
|
...config,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useConversationTagsQuery = (
|
export const useConversationTagsQuery = (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import { ContentTypes } from 'librechat-data-provider';
|
import { ContentTypes } from 'librechat-data-provider';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
|
|
@ -7,8 +7,20 @@ export default function useCopyToClipboard({
|
||||||
text,
|
text,
|
||||||
content,
|
content,
|
||||||
}: Partial<Pick<TMessage, '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(
|
const copyToClipboard = useCallback(
|
||||||
(setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
|
(setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
|
||||||
|
if (copyTimeoutRef.current) {
|
||||||
|
clearTimeout(copyTimeoutRef.current);
|
||||||
|
}
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
let messageText = text ?? '';
|
let messageText = text ?? '';
|
||||||
if (content) {
|
if (content) {
|
||||||
|
|
@ -22,7 +34,7 @@ export default function useCopyToClipboard({
|
||||||
}
|
}
|
||||||
copy(messageText, { format: 'text/plain' });
|
copy(messageText, { format: 'text/plain' });
|
||||||
|
|
||||||
setTimeout(() => {
|
copyTimeoutRef.current = setTimeout(() => {
|
||||||
setIsCopied(false);
|
setIsCopied(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ export default {
|
||||||
com_ui_chats: 'الدردشات',
|
com_ui_chats: 'الدردشات',
|
||||||
com_ui_share: 'مشاركة',
|
com_ui_share: 'مشاركة',
|
||||||
com_ui_copy_link: 'نسخ الرابط',
|
com_ui_copy_link: 'نسخ الرابط',
|
||||||
com_ui_update_link: 'رابط التحديث',
|
|
||||||
com_ui_create_link: 'إنشاء رابط',
|
com_ui_create_link: 'إنشاء رابط',
|
||||||
com_ui_share_link_to_chat: 'شارك الرابط في الدردشة',
|
com_ui_share_link_to_chat: 'شارك الرابط في الدردشة',
|
||||||
com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة',
|
com_ui_share_error: 'حدث خطأ أثناء مشاركة رابط الدردشة',
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,6 @@ export default {
|
||||||
com_ui_share_var: 'Compartilhar {0}',
|
com_ui_share_var: 'Compartilhar {0}',
|
||||||
com_ui_enter_var: 'Inserir {0}',
|
com_ui_enter_var: 'Inserir {0}',
|
||||||
com_ui_copy_link: 'Copiar link',
|
com_ui_copy_link: 'Copiar link',
|
||||||
com_ui_update_link: 'Atualizar link',
|
|
||||||
com_ui_create_link: 'Criar link',
|
com_ui_create_link: 'Criar link',
|
||||||
com_ui_share_to_all_users: 'Compartilhar com todos os usuários',
|
com_ui_share_to_all_users: 'Compartilhar com todos os usuários',
|
||||||
com_ui_my_prompts: 'Meus Prompts',
|
com_ui_my_prompts: 'Meus Prompts',
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,6 @@ export default {
|
||||||
com_ui_share: 'Teilen',
|
com_ui_share: 'Teilen',
|
||||||
com_ui_share_var: '{0} teilen',
|
com_ui_share_var: '{0} teilen',
|
||||||
com_ui_copy_link: 'Link kopieren',
|
com_ui_copy_link: 'Link kopieren',
|
||||||
com_ui_update_link: 'Link aktualisieren',
|
|
||||||
com_ui_create_link: 'Link erstellen',
|
com_ui_create_link: 'Link erstellen',
|
||||||
com_ui_share_to_all_users: 'Mit allen Benutzern teilen',
|
com_ui_share_to_all_users: 'Mit allen Benutzern teilen',
|
||||||
com_ui_my_prompts: 'Meine Prompts',
|
com_ui_my_prompts: 'Meine Prompts',
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export default {
|
||||||
com_download_expired: '(download expired)',
|
com_download_expired: '(download expired)',
|
||||||
com_download_expires: '(click here to download - expires {0})',
|
com_download_expires: '(click here to download - expires {0})',
|
||||||
com_click_to_download: '(click here to download)',
|
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_select_assistant: 'Select an Assistant',
|
||||||
com_sidepanel_parameters: 'Parameters',
|
com_sidepanel_parameters: 'Parameters',
|
||||||
com_sidepanel_assistant_builder: 'Assistant Builder',
|
com_sidepanel_assistant_builder: 'Assistant Builder',
|
||||||
|
|
@ -354,7 +354,6 @@ export default {
|
||||||
com_ui_share_var: 'Share {0}',
|
com_ui_share_var: 'Share {0}',
|
||||||
com_ui_enter_var: 'Enter {0}',
|
com_ui_enter_var: 'Enter {0}',
|
||||||
com_ui_copy_link: 'Copy link',
|
com_ui_copy_link: 'Copy link',
|
||||||
com_ui_update_link: 'Update link',
|
|
||||||
com_ui_create_link: 'Create link',
|
com_ui_create_link: 'Create link',
|
||||||
com_ui_share_to_all_users: 'Share to all users',
|
com_ui_share_to_all_users: 'Share to all users',
|
||||||
com_ui_my_prompts: 'My Prompts',
|
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_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_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_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_create_message: 'Your name and any messages you add after sharing stay private.',
|
||||||
com_ui_share_created_message:
|
com_ui_share_created_message:
|
||||||
'A shared link to your chat has been created. Manage previously shared chats at any time via Settings.',
|
'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_add_multi_conversation: 'Add multi-conversation',
|
||||||
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
|
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
|
||||||
com_ui_page: 'Page',
|
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:
|
com_auth_error_login:
|
||||||
'Unable to login with the information provided. Please check your credentials and try again.',
|
'Unable to login with the information provided. Please check your credentials and try again.',
|
||||||
com_auth_error_login_rl:
|
com_auth_error_login_rl:
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,6 @@ export default {
|
||||||
com_ui_create: 'Crear',
|
com_ui_create: 'Crear',
|
||||||
com_ui_share: 'Compartir',
|
com_ui_share: 'Compartir',
|
||||||
com_ui_copy_link: 'Copiar enlace',
|
com_ui_copy_link: 'Copiar enlace',
|
||||||
com_ui_update_link: 'Actualizar enlace',
|
|
||||||
com_ui_create_link: 'Crear enlace',
|
com_ui_create_link: 'Crear enlace',
|
||||||
com_ui_share_link_to_chat: 'Compartir enlace en el chat',
|
com_ui_share_link_to_chat: 'Compartir enlace en el chat',
|
||||||
com_ui_share_error: 'Hubo un error al compartir el enlace del chat',
|
com_ui_share_error: 'Hubo un error al compartir el enlace del chat',
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,6 @@ export default {
|
||||||
com_ui_share: 'Jaa',
|
com_ui_share: 'Jaa',
|
||||||
com_ui_share_var: 'Jaa {0}',
|
com_ui_share_var: 'Jaa {0}',
|
||||||
com_ui_copy_link: 'Kopioi linkki',
|
com_ui_copy_link: 'Kopioi linkki',
|
||||||
com_ui_update_link: 'Päivitä linkki',
|
|
||||||
com_ui_create_link: 'Luo linkki',
|
com_ui_create_link: 'Luo linkki',
|
||||||
com_ui_share_to_all_users: 'Jaa kaikille käyttäjille',
|
com_ui_share_to_all_users: 'Jaa kaikille käyttäjille',
|
||||||
com_ui_my_prompts: 'Omat syötteet',
|
com_ui_my_prompts: 'Omat syötteet',
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,6 @@ export default {
|
||||||
com_ui_copied: 'Copié !',
|
com_ui_copied: 'Copié !',
|
||||||
com_ui_copy_code: 'Copier le code',
|
com_ui_copy_code: 'Copier le code',
|
||||||
com_ui_copy_link: 'Copier le lien',
|
com_ui_copy_link: 'Copier le lien',
|
||||||
com_ui_update_link: 'Mettre à jour le lien',
|
|
||||||
com_ui_create_link: 'Créer un lien',
|
com_ui_create_link: 'Créer un lien',
|
||||||
com_nav_source_chat: 'Afficher la conversation source',
|
com_nav_source_chat: 'Afficher la conversation source',
|
||||||
com_ui_date_today: 'Aujourd\'hui',
|
com_ui_date_today: 'Aujourd\'hui',
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,6 @@ export default {
|
||||||
com_ui_create: 'צור',
|
com_ui_create: 'צור',
|
||||||
com_ui_share: 'שתף',
|
com_ui_share: 'שתף',
|
||||||
com_ui_copy_link: 'העתק קישור',
|
com_ui_copy_link: 'העתק קישור',
|
||||||
com_ui_update_link: 'עדכן קישור',
|
|
||||||
com_ui_create_link: 'צור קישור',
|
com_ui_create_link: 'צור קישור',
|
||||||
com_ui_share_link_to_chat: 'שתף קישור בצ\'אט',
|
com_ui_share_link_to_chat: 'שתף קישור בצ\'אט',
|
||||||
com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט',
|
com_ui_share_error: 'אירעה שגיאה בעת שיתוף קישור הצ\'אט',
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ export default {
|
||||||
com_ui_chats: 'chat',
|
com_ui_chats: 'chat',
|
||||||
com_ui_share: 'Bagikan',
|
com_ui_share: 'Bagikan',
|
||||||
com_ui_copy_link: 'Salin tautan',
|
com_ui_copy_link: 'Salin tautan',
|
||||||
com_ui_update_link: 'Perbarui tautan',
|
|
||||||
com_ui_create_link: 'Buat tautan',
|
com_ui_create_link: 'Buat tautan',
|
||||||
com_ui_share_link_to_chat: 'Bagikan tautan ke chat',
|
com_ui_share_link_to_chat: 'Bagikan tautan ke chat',
|
||||||
com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat',
|
com_ui_share_error: 'Terjadi kesalahan saat membagikan tautan chat',
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,6 @@ export default {
|
||||||
com_ui_create: 'Crea',
|
com_ui_create: 'Crea',
|
||||||
com_ui_share: 'Condividi',
|
com_ui_share: 'Condividi',
|
||||||
com_ui_copy_link: 'Copia link',
|
com_ui_copy_link: 'Copia link',
|
||||||
com_ui_update_link: 'Aggiorna link',
|
|
||||||
com_ui_create_link: 'Crea link',
|
com_ui_create_link: 'Crea link',
|
||||||
com_ui_share_link_to_chat: 'Condividi link a chat',
|
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',
|
com_ui_share_error: 'Si è verificato un errore durante la condivisione del link della chat',
|
||||||
|
|
|
||||||
|
|
@ -291,7 +291,6 @@ export default {
|
||||||
com_ui_share_var: '{0} を共有',
|
com_ui_share_var: '{0} を共有',
|
||||||
com_ui_enter_var: '{0} を入力',
|
com_ui_enter_var: '{0} を入力',
|
||||||
com_ui_copy_link: 'リンクをコピー',
|
com_ui_copy_link: 'リンクをコピー',
|
||||||
com_ui_update_link: 'リンクを更新する',
|
|
||||||
com_ui_create_link: 'リンクを作成する',
|
com_ui_create_link: 'リンクを作成する',
|
||||||
com_ui_share_to_all_users: '全ユーザーと共有',
|
com_ui_share_to_all_users: '全ユーザーと共有',
|
||||||
com_ui_my_prompts: 'マイ プロンプト',
|
com_ui_my_prompts: 'マイ プロンプト',
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ export default {
|
||||||
com_ui_chats: '채팅',
|
com_ui_chats: '채팅',
|
||||||
com_ui_share: '공유하기',
|
com_ui_share: '공유하기',
|
||||||
com_ui_copy_link: '링크 복사',
|
com_ui_copy_link: '링크 복사',
|
||||||
com_ui_update_link: '링크 업데이트',
|
|
||||||
com_ui_create_link: '링크 만들기',
|
com_ui_create_link: '링크 만들기',
|
||||||
com_ui_share_link_to_chat: '채팅으로 링크 공유하기',
|
com_ui_share_link_to_chat: '채팅으로 링크 공유하기',
|
||||||
com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다',
|
com_ui_share_error: '채팅 링크를 공유하는 동안 오류가 발생했습니다',
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ export default {
|
||||||
com_ui_chats: 'chats',
|
com_ui_chats: 'chats',
|
||||||
com_ui_share: 'Delen',
|
com_ui_share: 'Delen',
|
||||||
com_ui_copy_link: 'Link kopiëren',
|
com_ui_copy_link: 'Link kopiëren',
|
||||||
com_ui_update_link: 'Link bijwerken',
|
|
||||||
com_ui_create_link: 'Link aanmaken',
|
com_ui_create_link: 'Link aanmaken',
|
||||||
com_ui_share_link_to_chat: 'Deel link naar chat',
|
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',
|
com_ui_share_error: 'Er is een fout opgetreden bij het delen van de chatlink',
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ export default {
|
||||||
'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc',
|
'Wszystkie rozmowy z AI w jednym miejscu. Płatność za połączenie, a nie za miesiąc',
|
||||||
com_ui_share: 'Udostępnij',
|
com_ui_share: 'Udostępnij',
|
||||||
com_ui_copy_link: 'Skopiuj link',
|
com_ui_copy_link: 'Skopiuj link',
|
||||||
com_ui_update_link: 'Zaktualizuj link',
|
|
||||||
com_ui_create_link: 'Utwórz link',
|
com_ui_create_link: 'Utwórz link',
|
||||||
com_ui_share_link_to_chat: 'Udostępnij link w czacie',
|
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',
|
com_ui_share_error: 'Wystąpił błąd podczas udostępniania linku do czatu',
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ export default {
|
||||||
com_ui_connect: 'Подключить',
|
com_ui_connect: 'Подключить',
|
||||||
com_ui_share: 'Поделиться',
|
com_ui_share: 'Поделиться',
|
||||||
com_ui_copy_link: 'Скопировать ссылку',
|
com_ui_copy_link: 'Скопировать ссылку',
|
||||||
com_ui_update_link: 'Обновить ссылку',
|
|
||||||
com_ui_create_link: 'Создать ссылку',
|
com_ui_create_link: 'Создать ссылку',
|
||||||
com_ui_share_link_to_chat: 'Поделиться ссылкой в чате',
|
com_ui_share_link_to_chat: 'Поделиться ссылкой в чате',
|
||||||
com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат',
|
com_ui_share_error: 'Произошла ошибка при попытке поделиться ссылкой на чат',
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ export default {
|
||||||
com_ui_chats: 'chattar',
|
com_ui_chats: 'chattar',
|
||||||
com_ui_share: 'Dela',
|
com_ui_share: 'Dela',
|
||||||
com_ui_copy_link: 'Kopiera länk',
|
com_ui_copy_link: 'Kopiera länk',
|
||||||
com_ui_update_link: 'Uppdatera länk',
|
|
||||||
com_ui_create_link: 'Skapa länk',
|
com_ui_create_link: 'Skapa länk',
|
||||||
com_ui_share_link_to_chat: 'Dela länk till chatt',
|
com_ui_share_link_to_chat: 'Dela länk till chatt',
|
||||||
com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken',
|
com_ui_share_error: 'Ett fel uppstod vid delning av chattlänken',
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,6 @@ export default {
|
||||||
com_ui_create: 'Oluştur',
|
com_ui_create: 'Oluştur',
|
||||||
com_ui_share: 'Paylaş',
|
com_ui_share: 'Paylaş',
|
||||||
com_ui_copy_link: 'Bağlantıyı kopyala',
|
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_create_link: 'Bağlantı oluştur',
|
||||||
com_ui_share_link_to_chat: 'Sohbete bağlantı paylaş',
|
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',
|
com_ui_share_error: 'Sohbet bağlantısını paylaşırken bir hata oluştu',
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ export default {
|
||||||
com_ui_chats: 'cuộc trò chuyện',
|
com_ui_chats: 'cuộc trò chuyện',
|
||||||
com_ui_share: 'Chia sẻ',
|
com_ui_share: 'Chia sẻ',
|
||||||
com_ui_copy_link: 'Sao chép liên kết',
|
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_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_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',
|
com_ui_share_error: 'Có lỗi xảy ra khi chia sẻ liên kết trò chuyện',
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,6 @@ export default {
|
||||||
com_ui_share_var: '共享 {0}',
|
com_ui_share_var: '共享 {0}',
|
||||||
com_ui_enter_var: '输入 {0}',
|
com_ui_enter_var: '输入 {0}',
|
||||||
com_ui_copy_link: '复制链接',
|
com_ui_copy_link: '复制链接',
|
||||||
com_ui_update_link: '更新链接',
|
|
||||||
com_ui_create_link: '创建链接',
|
com_ui_create_link: '创建链接',
|
||||||
com_ui_share_to_all_users: '共享给所有用户',
|
com_ui_share_to_all_users: '共享给所有用户',
|
||||||
com_ui_my_prompts: '我的提示词',
|
com_ui_my_prompts: '我的提示词',
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ export default {
|
||||||
com_ui_chats: '對話',
|
com_ui_chats: '對話',
|
||||||
com_ui_share: '分享',
|
com_ui_share: '分享',
|
||||||
com_ui_copy_link: '複製連結',
|
com_ui_copy_link: '複製連結',
|
||||||
com_ui_update_link: '更新連結',
|
|
||||||
com_ui_create_link: '建立連結',
|
com_ui_create_link: '建立連結',
|
||||||
com_ui_share_link_to_chat: '分享連結到聊天',
|
com_ui_share_link_to_chat: '分享連結到聊天',
|
||||||
com_ui_share_error: '分享聊天連結時發生錯誤',
|
com_ui_share_error: '分享聊天連結時發生錯誤',
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,8 @@ html {
|
||||||
--surface-tertiary: var(--gray-100);
|
--surface-tertiary: var(--gray-100);
|
||||||
--surface-tertiary-alt: var(--white);
|
--surface-tertiary-alt: var(--white);
|
||||||
--surface-dialog: 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-light: var(--gray-200);
|
||||||
--border-medium-alt: var(--gray-300);
|
--border-medium-alt: var(--gray-300);
|
||||||
--border-medium: var(--gray-300);
|
--border-medium: var(--gray-300);
|
||||||
|
|
@ -114,7 +115,8 @@ html {
|
||||||
--surface-tertiary: var(--gray-700);
|
--surface-tertiary: var(--gray-700);
|
||||||
--surface-tertiary-alt: var(--gray-700);
|
--surface-tertiary-alt: var(--gray-700);
|
||||||
--surface-dialog: var(--gray-850);
|
--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-light: var(--gray-700);
|
||||||
--border-medium-alt: var(--gray-600);
|
--border-medium-alt: var(--gray-600);
|
||||||
--border-medium: var(--gray-600);
|
--border-medium: var(--gray-600);
|
||||||
|
|
@ -2412,3 +2414,42 @@ button.scroll-convo {
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
max-height: none !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);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,6 @@ export * from './textarea';
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
export * from './languages';
|
export * from './languages';
|
||||||
export * from './endpoints';
|
export * from './endpoints';
|
||||||
export * from './sharedLink';
|
|
||||||
export * from './localStorage';
|
export * from './localStorage';
|
||||||
export * from './promptGroups';
|
export * from './promptGroups';
|
||||||
export { default as cn } from './cn';
|
export { default as cn } from './cn';
|
||||||
|
|
|
||||||
|
|
@ -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],
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -29,6 +29,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 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-tertiary-alt': 'var(--surface-tertiary-alt)',
|
||||||
'surface-dialog': 'var(--surface-dialog)',
|
'surface-dialog': 'var(--surface-dialog)',
|
||||||
'surface-submit': 'var(--surface-submit)',
|
'surface-submit': 'var(--surface-submit)',
|
||||||
|
'surface-submit-hover': 'var(--surface-submit-hover)',
|
||||||
'border-light': 'var(--border-light)',
|
'border-light': 'var(--border-light)',
|
||||||
'border-medium': 'var(--border-medium)',
|
'border-medium': 'var(--border-medium)',
|
||||||
'border-medium-alt': 'var(--border-medium-alt)',
|
'border-medium-alt': 'var(--border-medium-alt)',
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -933,6 +933,7 @@
|
||||||
"lucide-react": "^0.394.0",
|
"lucide-react": "^0.394.0",
|
||||||
"match-sorter": "^6.3.4",
|
"match-sorter": "^6.3.4",
|
||||||
"msedge-tts": "^1.3.4",
|
"msedge-tts": "^1.3.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-input-number": "^7.4.2",
|
"rc-input-number": "^7.4.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-avatar-editor": "^13.0.2",
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,20 @@ export const messages = (conversationId: string, messageId?: string) =>
|
||||||
|
|
||||||
const shareRoot = '/api/share';
|
const shareRoot = '/api/share';
|
||||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||||
export const getSharedLinks = (pageNumber: string, isPublic: boolean) =>
|
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
|
||||||
`${shareRoot}?pageNumber=${pageNumber}&isPublic=${isPublic}`;
|
export const getSharedLinks = (
|
||||||
export const createSharedLink = shareRoot;
|
pageSize: number,
|
||||||
export const updateSharedLink = shareRoot;
|
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';
|
const keysEndpoint = '/api/keys';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,27 +41,29 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
|
||||||
return request.get(endpoints.shareMessages(shareId));
|
return request.get(endpoints.shareMessages(shareId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listSharedLinks = (
|
export const listSharedLinks = async (
|
||||||
params?: q.SharedLinkListParams,
|
params: q.SharedLinksListParams,
|
||||||
): Promise<q.SharedLinksResponse> => {
|
): Promise<q.SharedLinksResponse> => {
|
||||||
const pageNumber = (params?.pageNumber ?? '1') || '1'; // Default to page 1 if not provided
|
const { pageSize, isPublic, sortBy, sortDirection, search, cursor } = params;
|
||||||
const isPublic = params?.isPublic ?? true; // Default to true if not provided
|
|
||||||
return request.get(endpoints.getSharedLinks(pageNumber, isPublic));
|
return request.get(
|
||||||
|
endpoints.getSharedLinks(pageSize, isPublic, sortBy, sortDirection, search, cursor),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {
|
export function getSharedLink(conversationId: string): Promise<t.TSharedLinkGetResponse> {
|
||||||
return request.get(endpoints.shareMessages(shareId));
|
return request.get(endpoints.getSharedLink(conversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> {
|
export function createSharedLink(conversationId: string): Promise<t.TSharedLinkResponse> {
|
||||||
return request.post(endpoints.createSharedLink, payload);
|
return request.post(endpoints.createSharedLink(conversationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateSharedLink(payload: t.TSharedLinkRequest): Promise<t.TSharedLinkResponse> {
|
export function updateSharedLink(shareId: string): Promise<t.TSharedLinkResponse> {
|
||||||
return request.patch(endpoints.updateSharedLink, payload);
|
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));
|
return request.delete(endpoints.shareMessages(shareId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
export const useGetUserBalance = (
|
||||||
config?: UseQueryOptions<string>,
|
config?: UseQueryOptions<string>,
|
||||||
): QueryObserverResult<string> => {
|
): QueryObserverResult<string> => {
|
||||||
|
|
@ -306,7 +329,9 @@ export const useRegisterUserMutation = (
|
||||||
options?: m.RegistrationOptions,
|
options?: m.RegistrationOptions,
|
||||||
): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => {
|
): UseMutationResult<t.TError, unknown, t.TRegisterUser, unknown> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation((payload: t.TRegisterUser) => dataService.register(payload), {
|
return useMutation<t.TRegisterUserResponse, t.TError, t.TRegisterUser>(
|
||||||
|
(payload: t.TRegisterUser) => dataService.register(payload),
|
||||||
|
{
|
||||||
...options,
|
...options,
|
||||||
onSuccess: (...args) => {
|
onSuccess: (...args) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.user]);
|
queryClient.invalidateQueries([QueryKeys.user]);
|
||||||
|
|
@ -314,7 +339,8 @@ export const useRegisterUserMutation = (
|
||||||
options.onSuccess(...args);
|
options.onSuccess(...args);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRefreshTokenMutation = (): UseMutationResult<
|
export const useRefreshTokenMutation = (): UseMutationResult<
|
||||||
|
|
|
||||||
|
|
@ -719,13 +719,12 @@ export const tSharedLinkSchema = z.object({
|
||||||
conversationId: z.string(),
|
conversationId: z.string(),
|
||||||
shareId: z.string(),
|
shareId: z.string(),
|
||||||
messages: z.array(z.string()),
|
messages: z.array(z.string()),
|
||||||
isAnonymous: z.boolean(),
|
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
isVisible: z.boolean(),
|
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSharedLink = z.infer<typeof tSharedLinkSchema>;
|
export type TSharedLink = z.infer<typeof tSharedLinkSchema>;
|
||||||
|
|
||||||
export const tConversationTagSchema = z.object({
|
export const tConversationTagSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -170,15 +170,17 @@ export type TArchiveConversationResponse = TConversation;
|
||||||
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
|
export type TSharedMessagesResponse = Omit<TSharedLink, 'messages'> & {
|
||||||
messages: TMessage[];
|
messages: TMessage[];
|
||||||
};
|
};
|
||||||
export type TSharedLinkRequest = Partial<
|
|
||||||
Omit<TSharedLink, 'messages' | 'createdAt' | 'updatedAt'>
|
|
||||||
> & {
|
|
||||||
conversationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TSharedLinkResponse = TSharedLink;
|
export type TCreateShareLinkRequest = Pick<TConversation, 'conversationId'>;
|
||||||
export type TSharedLinksResponse = TSharedLink[];
|
|
||||||
export type TDeleteSharedLinkResponse = TSharedLink;
|
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
|
// type for getting conversation tags
|
||||||
export type TConversationTagsResponse = TConversationTag[];
|
export type TConversationTagsResponse = TConversationTag[];
|
||||||
|
|
@ -203,12 +205,10 @@ export type TDuplicateConvoRequest = {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TDuplicateConvoResponse =
|
export type TDuplicateConvoResponse = {
|
||||||
| {
|
|
||||||
conversation: TConversation;
|
conversation: TConversation;
|
||||||
messages: TMessage[];
|
messages: TMessage[];
|
||||||
}
|
};
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export type TForkConvoRequest = {
|
export type TForkConvoRequest = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ export type MutationOptions<
|
||||||
onSuccess?: (data: Response, variables: Request, context?: Context) => void;
|
onSuccess?: (data: Response, variables: Request, context?: Context) => void;
|
||||||
onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>;
|
onMutate?: (variables: Request) => Snapshot | Promise<Snapshot>;
|
||||||
onError?: (error: Error, variables: Request, context?: Context, snapshot?: Snapshot) => void;
|
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 = {
|
export type TGenTitleRequest = {
|
||||||
|
|
@ -186,7 +192,12 @@ export type ArchiveConvoOptions = MutationOptions<
|
||||||
types.TArchiveConversationRequest
|
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 =
|
export type TUpdatePromptContext =
|
||||||
| {
|
| {
|
||||||
|
|
@ -298,3 +309,9 @@ export type ToolCallMutationOptions<T extends ToolId> = MutationOptions<
|
||||||
ToolCallResponse,
|
ToolCallResponse,
|
||||||
ToolParams<T>
|
ToolParams<T>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type TDeleteSharedLinkResponse = {
|
||||||
|
success: boolean;
|
||||||
|
shareId: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,23 +41,34 @@ export type ConversationUpdater = (
|
||||||
export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
|
export type SharedMessagesResponse = Omit<s.TSharedLink, 'messages'> & {
|
||||||
messages: s.TMessage[];
|
messages: s.TMessage[];
|
||||||
};
|
};
|
||||||
export type SharedLinkListParams = Omit<ConversationListParams, 'isArchived' | 'conversationId'> & {
|
|
||||||
isPublic?: boolean;
|
export interface SharedLinksListParams {
|
||||||
|
pageSize: number;
|
||||||
|
isPublic: boolean;
|
||||||
|
sortBy: 'title' | 'createdAt';
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
|
search?: string;
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SharedLinkItem = {
|
||||||
|
shareId: string;
|
||||||
|
title: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
conversationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SharedLinksResponse = Omit<ConversationListResponse, 'conversations' | 'messages'> & {
|
export interface SharedLinksResponse {
|
||||||
sharedLinks: s.TSharedLink[];
|
links: SharedLinkItem[];
|
||||||
};
|
nextCursor: string | null;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Type for the response from the conversation list API
|
export interface SharedLinkQueryData {
|
||||||
export type SharedLinkListResponse = {
|
pages: SharedLinksResponse[];
|
||||||
sharedLinks: s.TSharedLink[];
|
pageParams: (string | null)[];
|
||||||
pageNumber: string;
|
}
|
||||||
pageSize: string | number;
|
|
||||||
pages: string | number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SharedLinkListData = InfiniteData<SharedLinkListResponse>;
|
|
||||||
|
|
||||||
export type AllPromptGroupsFilterRequest = {
|
export type AllPromptGroupsFilterRequest = {
|
||||||
category: string;
|
category: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue