mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-06 02:28:51 +01:00
✨ feat: Implement Conversation Duplication & UI Improvements (#5036)
* feat(ui): enhance conversation components and add duplication - feat: add conversation duplication functionality - fix: resolve OGDialogTemplate display issues - style: improve mobile dropdown component design - chore: standardize shared link title formatting * style: update active item background color in select-item * feat(conversation): add duplicate conversation functionality and UI integration * feat(conversation): enable title renaming on double-click and improve input focus styles * fix(conversation): remove "(Copy)" suffix from duplicated conversation title in logging * fix(RevokeKeysButton): correct className duration property for smoother transitions * refactor(conversation): ensure proper parent-child relationships and timestamps when message cloning --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
649c7a6032
commit
e8bde332c2
24 changed files with 717 additions and 85 deletions
|
|
@ -82,7 +82,7 @@ const AttachFile = ({ isRTL, disabled, setToolResource, handleFileChange }: Atta
|
|||
|
||||
return (
|
||||
<FileUpload ref={inputRef} handleFileChange={handleFileChange}>
|
||||
<div className="relative">
|
||||
<div className="relative select-none">
|
||||
<DropdownPopup
|
||||
menuId="attach-file-menu"
|
||||
isOpen={isPopoverActive}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export default function Conversation({
|
|||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (currentConvoId === conversationId || isPopoverActive) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -155,7 +156,7 @@ export default function Conversation({
|
|||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight outline-none"
|
||||
className="w-full rounded bg-transparent p-0.5 text-sm leading-tight focus-visible:outline-none"
|
||||
value={titleInput ?? ''}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
@ -199,7 +200,17 @@ export default function Conversation({
|
|||
size={20}
|
||||
context="menu-item"
|
||||
/>
|
||||
<div className="relative line-clamp-1 flex-1 grow overflow-hidden">{title}</div>
|
||||
<div
|
||||
className="relative line-clamp-1 flex-1 grow overflow-hidden"
|
||||
onDoubleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{isActiveConvo ? (
|
||||
<div className="absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l" />
|
||||
) : (
|
||||
|
|
@ -215,16 +226,17 @@ export default function Conversation({
|
|||
: 'hidden group-focus-within:flex group-hover:flex',
|
||||
)}
|
||||
>
|
||||
<ConvoOptions
|
||||
title={title}
|
||||
renaming={renaming}
|
||||
retainView={retainView}
|
||||
renameHandler={renameHandler}
|
||||
isActiveConvo={isActiveConvo}
|
||||
conversationId={conversationId}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
{!renaming && (
|
||||
<ConvoOptions
|
||||
title={title}
|
||||
retainView={retainView}
|
||||
renameHandler={renameHandler}
|
||||
isActiveConvo={isActiveConvo}
|
||||
conversationId={conversationId}
|
||||
isPopoverActive={isPopoverActive}
|
||||
setIsPopoverActive={setIsPopoverActive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useId } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Ellipsis, Share2, Archive, Pen, Trash } from 'lucide-react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { Ellipsis, Share2, Copy, Archive, Pen, Trash } from 'lucide-react';
|
||||
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useLocalize, useArchiveHandler } from '~/hooks';
|
||||
import { useLocalize, useArchiveHandler, useNavigateToConvo } from '~/hooks';
|
||||
import { useToastContext, useChatContext } from '~/Providers';
|
||||
import { useDuplicateConversationMutation } from '~/data-provider';
|
||||
import { DropdownPopup } from '~/components/ui';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import ShareButton from './ShareButton';
|
||||
|
|
@ -12,7 +14,6 @@ import { cn } from '~/utils';
|
|||
export default function ConvoOptions({
|
||||
conversationId,
|
||||
title,
|
||||
renaming,
|
||||
retainView,
|
||||
renameHandler,
|
||||
isPopoverActive,
|
||||
|
|
@ -21,7 +22,6 @@ export default function ConvoOptions({
|
|||
}: {
|
||||
conversationId: string | null;
|
||||
title: string | null;
|
||||
renaming: boolean;
|
||||
retainView: () => void;
|
||||
renameHandler: (e: MouseEvent) => void;
|
||||
isPopoverActive: boolean;
|
||||
|
|
@ -29,10 +29,37 @@ export default function ConvoOptions({
|
|||
isActiveConvo: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { index } = useChatContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
||||
const { navigateToConvo } = useNavigateToConvo(index);
|
||||
const { showToast } = useToastContext();
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const archiveHandler = useArchiveHandler(conversationId, true, retainView);
|
||||
|
||||
const duplicateConversation = useDuplicateConversationMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data != null) {
|
||||
navigateToConvo(data.conversation);
|
||||
showToast({
|
||||
message: localize('com_ui_duplication_success'),
|
||||
status: 'success',
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_duplication_processing'),
|
||||
status: 'info',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_duplication_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const shareHandler = () => {
|
||||
setIsPopoverActive(false);
|
||||
|
|
@ -44,27 +71,39 @@ export default function ConvoOptions({
|
|||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const duplicateHandler = () => {
|
||||
setIsPopoverActive(false);
|
||||
duplicateConversation.mutate({
|
||||
conversationId: conversationId ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: localize('com_ui_rename'),
|
||||
onClick: renameHandler,
|
||||
icon: <Pen className="icon-md mr-2 text-text-secondary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: shareHandler,
|
||||
icon: <Share2 className="icon-md mr-2 text-text-secondary" />,
|
||||
icon: <Share2 className="icon-sm mr-2 text-text-primary" />,
|
||||
show: startupConfig && startupConfig.sharedLinksEnabled,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_rename'),
|
||||
onClick: renameHandler,
|
||||
icon: <Pen className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_duplicate'),
|
||||
onClick: duplicateHandler,
|
||||
icon: <Copy className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_archive'),
|
||||
onClick: archiveHandler,
|
||||
icon: <Archive className="icon-md mr-2 text-text-secondary" />,
|
||||
icon: <Archive className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_delete'),
|
||||
onClick: deleteHandler,
|
||||
icon: <Trash className="icon-md mr-2 text-text-secondary" />,
|
||||
icon: <Trash className="icon-sm mr-2 text-text-primary" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -76,7 +115,7 @@ export default function ConvoOptions({
|
|||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
<Menu.MenuButton
|
||||
id="conversation-menu-button"
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
className={cn(
|
||||
|
|
@ -84,11 +123,10 @@ export default function ConvoOptions({
|
|||
isActiveConvo === true
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
|
||||
renaming === true ? 'pointer-events-none opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="icon-md text-text-secondary" aria-hidden={true} />
|
||||
</Ariakit.MenuButton>
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
menuId={menuId}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useUpdateSharedLinkMutation } from '~/data-provider';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function SharedLinkButton({
|
||||
|
|
@ -112,7 +111,7 @@ export default function SharedLinkButton({
|
|||
onClick={() => {
|
||||
handlers.handler();
|
||||
}}
|
||||
className="btn btn-primary flex items-center"
|
||||
className="btn btn-primary flex items-center justify-center"
|
||||
>
|
||||
{isCopying && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -177,11 +177,14 @@ const Nav = ({
|
|||
<SearchBar clearSearch={clearSearch} isSmallScreen={isSmallScreen} />
|
||||
)}
|
||||
{hasAccessToBookmarks === true && (
|
||||
<BookmarkNav
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
<BookmarkNav
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) =
|
|||
}
|
||||
<input
|
||||
type="text"
|
||||
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||
value={text}
|
||||
onChange={onChange}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ function ShareLinkRow({ sharedLink }: { sharedLink: TSharedLink }) {
|
|||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_conversation')}
|
||||
title={localize('com_ui_delete_shared_link')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function SharedLinks() {
|
|||
title={localize('com_nav_shared_links')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ShareLinkTable />}
|
||||
main={<ShareLinkTable className="w-full" />}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -166,8 +166,7 @@ const AdminSettings = () => {
|
|||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
className="border border-border-light bg-surface-primary"
|
||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -166,8 +166,7 @@ const AdminSettings = () => {
|
|||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={roleDropdownItems}
|
||||
className="border border-border-light bg-surface-primary"
|
||||
itemClassName="hover:bg-surface-tertiary items-center justify-center"
|
||||
itemClassName="items-center justify-center"
|
||||
sameWidth={true}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import * as Select from '@ariakit/react/select';
|
||||
import { cn } from '~/utils/';
|
||||
import type { Option } from '~/common';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
|
|
|
|||
|
|
@ -45,10 +45,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
{trigger}
|
||||
<Ariakit.Menu
|
||||
id={menuId}
|
||||
className={cn(
|
||||
'absolute z-50 mt-2 overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
className,
|
||||
)}
|
||||
className={cn('popover-ui z-50', className)}
|
||||
gutter={gutter}
|
||||
modal={modal}
|
||||
sameWidth={sameWidth}
|
||||
|
|
@ -62,7 +59,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
<Ariakit.MenuItem
|
||||
key={index}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg p-2.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover',
|
||||
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
|
||||
itemClassName,
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
|
|
@ -75,7 +72,7 @@ const DropdownPopup: React.FC<DropdownProps> = ({
|
|||
}}
|
||||
>
|
||||
{item.icon != null && (
|
||||
<span className={cn('mr-2 h-5 w-5', iconClassName)} aria-hidden="true">
|
||||
<span className={cn('mr-2 size-4', iconClassName)} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,29 +59,33 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
|
|||
overlayClassName={overlayClassName}
|
||||
showCloseButton={showCloseButton}
|
||||
ref={ref}
|
||||
className={cn('border-none bg-background text-foreground', className ?? '')}
|
||||
className={cn('w-11/12 border-none bg-background text-foreground', className ?? '')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<OGDialogHeader className={cn(headerClassName ?? '')}>
|
||||
<OGDialogTitle>{title}</OGDialogTitle>
|
||||
{description && <OGDialogDescription className="">{description}</OGDialogDescription>}
|
||||
{description && (
|
||||
<OGDialogDescription className="items-center justify-center">
|
||||
{description}
|
||||
</OGDialogDescription>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
<div className={cn('px-0', mainClassName)}>{main != null ? main : null}</div>
|
||||
<div className={cn('px-0 py-2', mainClassName)}>{main != null ? main : null}</div>
|
||||
<OGDialogFooter className={footerClassName}>
|
||||
<div>{leftButtons != null ? leftButtons : null}</div>
|
||||
<div className="flex h-auto gap-3">
|
||||
<div>{leftButtons != null ? <div className="mt-3 sm:mt-0">{leftButtons}</div> : null}</div>
|
||||
<div className="flex h-auto gap-3 max-sm:w-full max-sm:flex-col sm:flex-row">
|
||||
{buttons != null ? buttons : null}
|
||||
{showCancelButton && (
|
||||
<OGDialogClose className="btn btn-neutral border-token-border-light relative rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0">
|
||||
<OGDialogClose className="btn btn-neutral border-token-border-light relative justify-center rounded-lg text-sm ring-offset-2 focus:ring-2 focus:ring-black dark:ring-offset-0 max-sm:order-last max-sm:w-full sm:order-first">
|
||||
{Cancel}
|
||||
</OGDialogClose>
|
||||
)}
|
||||
{buttons != null ? buttons : null}
|
||||
{selection ? (
|
||||
<OGDialogClose
|
||||
onClick={selectHandler}
|
||||
className={`${
|
||||
selectClasses ?? defaultSelect
|
||||
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm`}
|
||||
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm max-sm:order-first max-sm:w-full sm:order-none`}
|
||||
>
|
||||
{selectText}
|
||||
</OGDialogClose>
|
||||
|
|
|
|||
|
|
@ -573,6 +573,43 @@ export const useDeleteConversationMutation = (
|
|||
);
|
||||
};
|
||||
|
||||
export const useDuplicateConversationMutation = (
|
||||
options?: t.DuplicateConvoOptions,
|
||||
): UseMutationResult<t.TDuplicateConvoResponse, unknown, t.TDuplicateConvoRequest, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onSuccess, ..._options } = options ?? {};
|
||||
return useMutation(
|
||||
(payload: t.TDuplicateConvoRequest) => dataService.duplicateConversation(payload),
|
||||
{
|
||||
onSuccess: (data, vars, context) => {
|
||||
const originalId = vars.conversationId ?? '';
|
||||
if (originalId.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(
|
||||
[QueryKeys.conversation, data.conversation.conversationId],
|
||||
data.conversation,
|
||||
);
|
||||
queryClient.setQueryData<t.ConversationData>([QueryKeys.allConversations], (convoData) => {
|
||||
if (!convoData) {
|
||||
return convoData;
|
||||
}
|
||||
return addConversation(convoData, data.conversation);
|
||||
});
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, data.conversation.conversationId],
|
||||
data.messages,
|
||||
);
|
||||
onSuccess?.(data, vars, context);
|
||||
},
|
||||
..._options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useForkConvoMutation = (
|
||||
options?: t.ForkConvoOptions,
|
||||
): UseMutationResult<t.TForkConvoResponse, unknown, t.TForkConvoRequest, unknown> => {
|
||||
|
|
|
|||
|
|
@ -297,6 +297,9 @@ export default {
|
|||
com_ui_mention: 'Mention an endpoint, assistant, or preset to quickly switch to it',
|
||||
com_ui_add_model_preset: 'Add a model or preset for an additional response',
|
||||
com_assistants_max_starters_reached: 'Max number of conversation starters reached',
|
||||
com_ui_duplication_success: 'Successfully duplicated conversation',
|
||||
com_ui_duplication_processing: 'Duplicating conversation...',
|
||||
com_ui_duplication_error: 'There was an error duplicating the conversation',
|
||||
com_ui_regenerate: 'Regenerate',
|
||||
com_ui_continue: 'Continue',
|
||||
com_ui_edit: 'Edit',
|
||||
|
|
@ -392,6 +395,7 @@ export default {
|
|||
'Are you sure you want to delete this Assistant? This cannot be undone.',
|
||||
com_ui_rename: 'Rename',
|
||||
com_ui_archive: 'Archive',
|
||||
com_ui_duplicate: 'Duplicate',
|
||||
com_ui_archive_error: 'Failed to archive conversation',
|
||||
com_ui_unarchive: 'Unarchive',
|
||||
com_ui_unarchive_error: 'Failed to unarchive conversation',
|
||||
|
|
@ -430,7 +434,6 @@ export default {
|
|||
com_ui_no_bookmarks: 'it seems like you have no bookmarks yet. Click on a chat and add a new one',
|
||||
com_ui_no_conversation_id: 'No conversation ID found',
|
||||
com_ui_add_multi_conversation: 'Add multi-conversation',
|
||||
com_ui_duplicate: 'Duplicate',
|
||||
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
|
||||
com_auth_error_login:
|
||||
'Unable to login with the information provided. Please check your credentials and try again.',
|
||||
|
|
@ -743,6 +746,7 @@ export default {
|
|||
com_nav_export_recursive: 'Recursive',
|
||||
com_nav_export_conversation: 'Export conversation',
|
||||
com_nav_export: 'Export',
|
||||
com_ui_delete_shared_link: 'Delete shared link?',
|
||||
com_nav_shared_links: 'Shared links',
|
||||
com_nav_shared_links_manage: 'Manage',
|
||||
com_nav_shared_links_empty: 'You have no shared links.',
|
||||
|
|
|
|||
|
|
@ -2371,7 +2371,7 @@ button.scroll-convo {
|
|||
}
|
||||
|
||||
.popover-ui:where(.dark, .dark *) {
|
||||
background-color: hsl(var(--background));
|
||||
background-color: hsl(var(--secondary));
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.25), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
|
@ -2392,7 +2392,7 @@ button.scroll-convo {
|
|||
}
|
||||
|
||||
.select-item[data-active-item] {
|
||||
background-color: hsl(var(--accent));
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue