MAJOR REFACTOR (#494)
* move index to home folder, create state and context files and barrell folder * Sanity Check Commit: reducer added to home.tsx manual QA all working * WIP: promptBar * fix missing json parse on folders and prompts * split context and add promptbar context * add context to nested prompt componets and componetize Folder componet * remove log * Create buttons folder and componetize sidebar action button * tidy up prompt handlers * componetized sidebar * added back chatbar componet to left side sidebar * monster commit: Componetized the common code between chatbar and promptbar into new componet Sidebar and added context to both bars * add useFetch service * added prettier import sort to keep imports ordered and easier to indentify * added react query and useFetch to work with RQ * added apiService, errorService and reactQuery * add callback and tidy up error service * refactor chat and child componets to useContext * fix extra calls and bad calls to mel endpoint * minor import cleanup --------- Co-authored-by: jc.durbin <jc.durbin@ardanis.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { FolderInterface } from '@/types/folder';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import Folder from '@/components/Folder';
|
||||
|
||||
import { ConversationComponent } from './Conversation';
|
||||
|
||||
interface Props {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const ChatFolders = ({ searchTerm }: Props) => {
|
||||
const {
|
||||
state: { folders, conversations },
|
||||
handleUpdateConversation,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const handleDrop = (e: any, folder: FolderInterface) => {
|
||||
if (e.dataTransfer) {
|
||||
const conversation = JSON.parse(e.dataTransfer.getData('conversation'));
|
||||
handleUpdateConversation(conversation, {
|
||||
key: 'folderId',
|
||||
value: folder.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ChatFolders = (currentFolder: FolderInterface) => {
|
||||
return (
|
||||
conversations &&
|
||||
conversations
|
||||
.filter((conversation) => conversation.folderId)
|
||||
.map((conversation, index) => {
|
||||
if (conversation.folderId === currentFolder.id) {
|
||||
return (
|
||||
<div key={index} className="ml-5 gap-2 border-l pl-2">
|
||||
<ConversationComponent conversation={conversation} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col pt-2">
|
||||
{folders
|
||||
.filter((folder) => folder.type === 'chat')
|
||||
.map((folder, index) => (
|
||||
<Folder
|
||||
key={index}
|
||||
searchTerm={searchTerm}
|
||||
currentFolder={folder}
|
||||
handleDrop={handleDrop}
|
||||
folderComponent={ChatFolders(folder)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IconFileExport, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { Import } from '../../Settings/Import';
|
||||
import { Key } from '../../Settings/Key';
|
||||
import { SidebarButton } from '../../Sidebar/SidebarButton';
|
||||
import ChatbarContext from '../Chatbar.context';
|
||||
import { ClearConversations } from './ClearConversations';
|
||||
import { PluginKeys } from './PluginKeys';
|
||||
|
||||
export const ChatbarSettings = () => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const {
|
||||
state: {
|
||||
apiKey,
|
||||
lightMode,
|
||||
serverSideApiKeyIsSet,
|
||||
serverSidePluginKeysSet,
|
||||
conversations,
|
||||
},
|
||||
dispatch: homeDispatch,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const {
|
||||
handleClearConversations,
|
||||
handleImportConversations,
|
||||
handleExportData,
|
||||
|
||||
handleApiKeyChange,
|
||||
} = useContext(ChatbarContext);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-1 border-t border-white/20 pt-1 text-sm">
|
||||
{conversations.length > 0 ? (
|
||||
<ClearConversations onClearConversations={handleClearConversations} />
|
||||
) : null}
|
||||
|
||||
<Import onImport={handleImportConversations} />
|
||||
|
||||
<SidebarButton
|
||||
text={t('Export data')}
|
||||
icon={<IconFileExport size={18} />}
|
||||
onClick={() => handleExportData()}
|
||||
/>
|
||||
|
||||
<SidebarButton
|
||||
text={lightMode === 'light' ? t('Dark mode') : t('Light mode')}
|
||||
icon={
|
||||
lightMode === 'light' ? <IconMoon size={18} /> : <IconSun size={18} />
|
||||
}
|
||||
onClick={() =>
|
||||
homeDispatch({
|
||||
field: 'lightMode',
|
||||
value: lightMode === 'light' ? 'dark' : 'light',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{!serverSideApiKeyIsSet ? (
|
||||
<Key apiKey={apiKey} onApiKeyChange={handleApiKeyChange} />
|
||||
) : null}
|
||||
|
||||
{!serverSidePluginKeysSet ? <PluginKeys /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { IconCheck, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { FC, useState } from 'react';
|
||||
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
|
||||
|
||||
interface Props {
|
||||
onClearConversations: () => void;
|
||||
}
|
||||
|
||||
export const ClearConversations: FC<Props> = ({ onClearConversations }) => {
|
||||
const [isConfirming, setIsConfirming] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const handleClearConversations = () => {
|
||||
onClearConversations();
|
||||
setIsConfirming(false);
|
||||
};
|
||||
|
||||
return isConfirming ? (
|
||||
<div className="flex w-full cursor-pointer items-center rounded-lg py-3 px-3 hover:bg-gray-500/10">
|
||||
<IconTrash size={18} />
|
||||
|
||||
<div className="ml-3 flex-1 text-left text-[12.5px] leading-3 text-white">
|
||||
{t('Are you sure?')}
|
||||
</div>
|
||||
|
||||
<div className="flex w-[40px]">
|
||||
<IconCheck
|
||||
className="ml-auto mr-1 min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearConversations();
|
||||
}}
|
||||
/>
|
||||
|
||||
<IconX
|
||||
className="ml-auto min-w-[20px] text-neutral-400 hover:text-neutral-100"
|
||||
size={18}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SidebarButton
|
||||
text={t('Clear conversations')}
|
||||
icon={<IconTrash size={18} />}
|
||||
onClick={() => setIsConfirming(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
IconCheck,
|
||||
IconMessage,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
DragEvent,
|
||||
KeyboardEvent,
|
||||
MouseEventHandler,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import SidebarActionButton from '@/components/Buttons/SidebarActionButton';
|
||||
import ChatbarContext from '@/components/Chatbar/Chatbar.context';
|
||||
|
||||
interface Props {
|
||||
conversation: Conversation;
|
||||
}
|
||||
|
||||
export const ConversationComponent = ({ conversation }: Props) => {
|
||||
const {
|
||||
state: { selectedConversation, messageIsStreaming },
|
||||
handleSelectConversation,
|
||||
handleUpdateConversation,
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const { handleDeleteConversation } = useContext(ChatbarContext);
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const handleEnterDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
selectedConversation && handleRename(selectedConversation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (
|
||||
e: DragEvent<HTMLButtonElement>,
|
||||
conversation: Conversation,
|
||||
) => {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData('conversation', JSON.stringify(conversation));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = (conversation: Conversation) => {
|
||||
if (renameValue.trim().length > 0) {
|
||||
handleUpdateConversation(conversation, {
|
||||
key: 'name',
|
||||
value: renameValue,
|
||||
});
|
||||
setRenameValue('');
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleting) {
|
||||
handleDeleteConversation(conversation);
|
||||
} else if (isRenaming) {
|
||||
handleRename(conversation);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleCancel: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(false);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleOpenRenameModal: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
selectedConversation && setRenameValue(selectedConversation.name);
|
||||
};
|
||||
const handleOpenDeleteModal: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleting(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming) {
|
||||
setIsDeleting(false);
|
||||
} else if (isDeleting) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [isRenaming, isDeleting]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
{isRenaming && selectedConversation?.id === conversation.id ? (
|
||||
<div className="flex w-full items-center gap-3 rounded-lg bg-[#343541]/90 p-3">
|
||||
<IconMessage size={18} />
|
||||
<input
|
||||
className="mr-12 flex-1 overflow-hidden overflow-ellipsis border-neutral-400 bg-transparent text-left text-[12.5px] leading-3 text-white outline-none focus:border-neutral-100"
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleEnterDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-3 rounded-lg p-3 text-sm transition-colors duration-200 hover:bg-[#343541]/90 ${
|
||||
messageIsStreaming ? 'disabled:cursor-not-allowed' : ''
|
||||
} ${
|
||||
selectedConversation?.id === conversation.id
|
||||
? 'bg-[#343541]/90'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleSelectConversation(conversation)}
|
||||
disabled={messageIsStreaming}
|
||||
draggable="true"
|
||||
onDragStart={(e) => handleDragStart(e, conversation)}
|
||||
>
|
||||
<IconMessage size={18} />
|
||||
<div
|
||||
className={`relative max-h-5 flex-1 overflow-hidden text-ellipsis whitespace-nowrap break-all text-left text-[12.5px] leading-3 ${
|
||||
selectedConversation?.id === conversation.id ? 'pr-12' : 'pr-1'
|
||||
}`}
|
||||
>
|
||||
{conversation.name}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(isDeleting || isRenaming) &&
|
||||
selectedConversation?.id === conversation.id && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<SidebarActionButton handleClick={handleConfirm}>
|
||||
<IconCheck size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton handleClick={handleCancel}>
|
||||
<IconX size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConversation?.id === conversation.id &&
|
||||
!isDeleting &&
|
||||
!isRenaming && (
|
||||
<div className="absolute right-1 z-10 flex text-gray-300">
|
||||
<SidebarActionButton handleClick={handleOpenRenameModal}>
|
||||
<IconPencil size={18} />
|
||||
</SidebarActionButton>
|
||||
<SidebarActionButton handleClick={handleOpenDeleteModal}>
|
||||
<IconTrash size={18} />
|
||||
</SidebarActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
import { ConversationComponent } from './Conversation';
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
}
|
||||
|
||||
export const Conversations = ({ conversations }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{conversations
|
||||
.filter((conversation) => !conversation.folderId)
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((conversation, index) => (
|
||||
<ConversationComponent key={index} conversation={conversation} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { IconKey } from '@tabler/icons-react';
|
||||
import { KeyboardEvent, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { PluginID, PluginKey } from '@/types/plugin';
|
||||
|
||||
import HomeContext from '@/pages/api/home/home.context';
|
||||
|
||||
import { SidebarButton } from '@/components/Sidebar/SidebarButton';
|
||||
|
||||
import ChatbarContext from '../Chatbar.context';
|
||||
|
||||
export const PluginKeys = () => {
|
||||
const { t } = useTranslation('sidebar');
|
||||
|
||||
const {
|
||||
state: { pluginKeys },
|
||||
} = useContext(HomeContext);
|
||||
|
||||
const { handlePluginKeyChange, handleClearPluginKey } =
|
||||
useContext(ChatbarContext);
|
||||
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleEnter = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
setIsChanging(false);
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarButton
|
||||
text={t('Plugin Keys')}
|
||||
icon={<IconKey size={18} />}
|
||||
onClick={() => setIsChanging(true)}
|
||||
/>
|
||||
|
||||
{isChanging && (
|
||||
<div
|
||||
className="z-100 fixed inset-0 flex items-center justify-center bg-black bg-opacity-50"
|
||||
onKeyDown={handleEnter}
|
||||
>
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="hidden sm:inline-block sm:h-screen sm:align-middle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="dark:border-netural-400 inline-block max-h-[400px] transform overflow-hidden rounded-lg border border-gray-300 bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all dark:bg-[#202123] sm:my-8 sm:max-h-[600px] sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="mb-10 text-4xl">Plugin Keys</div>
|
||||
|
||||
<div className="mt-6 rounded border p-4">
|
||||
<div className="text-xl font-bold">Google Search Plugin</div>
|
||||
<div className="mt-4 italic">
|
||||
Please enter your Google API Key and Google CSE ID to enable
|
||||
the Google Search Plugin.
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
Google API Key
|
||||
</div>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
type="password"
|
||||
value={
|
||||
pluginKeys
|
||||
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
|
||||
?.requiredKeys.find((k) => k.key === 'GOOGLE_API_KEY')
|
||||
?.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
const requiredKey = pluginKey.requiredKeys.find(
|
||||
(k) => k.key === 'GOOGLE_API_KEY',
|
||||
);
|
||||
|
||||
if (requiredKey) {
|
||||
const updatedPluginKey = {
|
||||
...pluginKey,
|
||||
requiredKeys: pluginKey.requiredKeys.map((k) => {
|
||||
if (k.key === 'GOOGLE_API_KEY') {
|
||||
return {
|
||||
...k,
|
||||
value: e.target.value,
|
||||
};
|
||||
}
|
||||
|
||||
return k;
|
||||
}),
|
||||
};
|
||||
|
||||
handlePluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: e.target.value,
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handlePluginKeyChange(newPluginKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 text-sm font-bold text-black dark:text-neutral-200">
|
||||
Google CSE ID
|
||||
</div>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-[#40414F] dark:text-neutral-100"
|
||||
type="password"
|
||||
value={
|
||||
pluginKeys
|
||||
.find((p) => p.pluginId === PluginID.GOOGLE_SEARCH)
|
||||
?.requiredKeys.find((k) => k.key === 'GOOGLE_CSE_ID')
|
||||
?.value
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
const requiredKey = pluginKey.requiredKeys.find(
|
||||
(k) => k.key === 'GOOGLE_CSE_ID',
|
||||
);
|
||||
|
||||
if (requiredKey) {
|
||||
const updatedPluginKey = {
|
||||
...pluginKey,
|
||||
requiredKeys: pluginKey.requiredKeys.map((k) => {
|
||||
if (k.key === 'GOOGLE_CSE_ID') {
|
||||
return {
|
||||
...k,
|
||||
value: e.target.value,
|
||||
};
|
||||
}
|
||||
|
||||
return k;
|
||||
}),
|
||||
};
|
||||
|
||||
handlePluginKeyChange(updatedPluginKey);
|
||||
}
|
||||
} else {
|
||||
const newPluginKey: PluginKey = {
|
||||
pluginId: PluginID.GOOGLE_SEARCH,
|
||||
requiredKeys: [
|
||||
{
|
||||
key: 'GOOGLE_API_KEY',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
key: 'GOOGLE_CSE_ID',
|
||||
value: e.target.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handlePluginKeyChange(newPluginKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={() => {
|
||||
const pluginKey = pluginKeys.find(
|
||||
(p) => p.pluginId === PluginID.GOOGLE_SEARCH,
|
||||
);
|
||||
|
||||
if (pluginKey) {
|
||||
handleClearPluginKey(pluginKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear Google Search Plugin Keys
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 w-full rounded-lg border border-neutral-500 px-4 py-2 text-neutral-900 shadow hover:bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:border-opacity-50 dark:bg-white dark:text-black dark:hover:bg-neutral-300"
|
||||
onClick={() => setIsChanging(false)}
|
||||
>
|
||||
{t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user