mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 19:18:52 +01:00
🔥🚀 feat: CDN (Firebase) & feat: account section (#1438)
* localization + api-endpoint * docs: added firebase documentation * chore: icons * chore: SettingsTabs * feat: account pannel; fix: gear icons * docs: position update * feat: firebase * feat: plugin support * route * fixed bugs with firebase and moved a lot of files * chore(DALLE3): using UUID v4 * feat: support for social strategies; moved '/images' path * fix: data ignored * gitignore update * docs: update firebase guide * refactor: Firebase - use singleton pattern for firebase initialization, initially on server start - reorganize imports, move firebase specific files to own service under Files - rename modules to remove 'avatar' redundancy - fix imports based on changes * ci(DALLE/DALLE3): fix tests to use logger and new expected outputs, add firebase tests * refactor(loadToolWithAuth): pass userId to tool as field * feat(images/parse): feat: Add URL Image Basename Extraction Implement a new module to extract the basename of an image from a given URL. This addition includes the function, which parses the URL and retrieves the basename using the Node.js 'url' and 'path' modules. The function is documented with JSDoc comments for better maintainability and understanding. This feature enhances the application's ability to handle and process image URLs efficiently. * refactor(addImages): function to use a more specific regular expression for observedImagePath based on the generated image markdown standard across the app * refactor(DALLE/DALLE3): utilize `getImageBasename` and `this.userId`; fix: pass correct image path to firebase url helper * fix(addImages): make more general to match any image markdown descriptor * fix(parse/getImageBasename): test result of this function for an actual image basename * ci(DALLE3): mock getImageBasename * refactor(AuthContext): use Recoil atom state for user * feat: useUploadAvatarMutation, react-query hook for avatar upload * fix(Toast): stack z-order of Toast over all components (1000) * refactor(showToast): add optional status field to avoid importing NotificationSeverity on each use of the function * refactor(routes/avatar): remove unnecessary get route, get userId from req.user.id, require auth on POST request * chore(uploadAvatar): TODO: remove direct use of Model, `User` * fix(client): fix Spinner imports * refactor(Avatar): use react-query hook, Toast, remove unnecessary states, add optimistic UI to upload * fix(avatar/localStrategy): correctly save local profile picture and cache bust for immediate rendering; fix: firebase init info message (only show once) * fix: use `includes` instead of `endsWith` for checking manual query of avatar image path in case more queries are appended (as is done in avatar/localStrategy) --------- Co-authored-by: Danny Avila <messagedaniel@protonmail.com>
This commit is contained in:
parent
bd4d23d314
commit
f19f5dca8e
59 changed files with 1855 additions and 172 deletions
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '~/hooks';
|
||||
import { TooltipProvider, Tooltip } from '~/components/ui';
|
||||
import { Conversations, Pages } from '../Conversations';
|
||||
import { Spinner } from '~/components';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import SearchBar from './SearchBar';
|
||||
import NavToggle from './NavToggle';
|
||||
import NavLinks from './NavLinks';
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ function NavLinks() {
|
|||
<Menu.Item as="div">
|
||||
<NavLink
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-none px-3 py-3 text-sm text-white transition-colors duration-200 hover:bg-gray-700"
|
||||
svg={() => <GearIcon />}
|
||||
svg={() => <GearIcon className="icon-md" />}
|
||||
text={localize('com_nav_settings')}
|
||||
clickHandler={() => setShowSettings(true)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { GearIcon, DataIcon } from '~/components/svg';
|
||||
import { GearIcon, DataIcon, UserIcon } from '~/components/svg';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { General, Data } from './SettingsTabs';
|
||||
import { General, Data, Account } from './SettingsTabs';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
|
|
@ -39,7 +39,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
|
|
@ -51,7 +51,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-gray-500 radix-state-active:bg-gray-800 radix-state-active:text-white',
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
|
|
@ -61,9 +61,22 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<DataIcon />
|
||||
{localize('com_nav_setting_data')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
className={cn(
|
||||
'group my-1 flex items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm text-black radix-state-active:bg-gray-100 radix-state-active:text-black dark:text-white dark:radix-state-active:bg-gray-800',
|
||||
isSmallScreen
|
||||
? 'flex-1 items-center justify-center text-sm dark:text-gray-500 dark:radix-state-active:text-white'
|
||||
: '',
|
||||
)}
|
||||
value="account"
|
||||
>
|
||||
<UserIcon />
|
||||
{localize('com_nav_setting_account')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<General />
|
||||
<Data />
|
||||
<Account />
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
18
client/src/components/Nav/SettingsTabs/Account/Account.tsx
Normal file
18
client/src/components/Nav/SettingsTabs/Account/Account.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import Avatar from './Avatar';
|
||||
import React from 'react';
|
||||
|
||||
function Account() {
|
||||
return (
|
||||
<Tabs.Content value="account" role="tabpanel" className="w-full md:min-h-[300px]">
|
||||
<div className="flex flex-col gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700">
|
||||
<Avatar />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b pb-3 last-of-type:border-b-0 dark:border-gray-700"></div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Account);
|
||||
145
client/src/components/Nav/SettingsTabs/Account/Avatar.tsx
Normal file
145
client/src/components/Nav/SettingsTabs/Account/Avatar.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { FileImage } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui';
|
||||
import { useUploadAvatarMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const sizeLimit = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
function Avatar() {
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [input, setinput] = useState<File | null>(null);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setDialogOpen(false);
|
||||
|
||||
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error:', error);
|
||||
showToast({ message: localize('com_ui_upload_error'), status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (input) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(input);
|
||||
} else {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (file && file.size <= sizeLimit) {
|
||||
setinput(file);
|
||||
setDialogOpen(true);
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_upload_invalid'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (!input) {
|
||||
console.error('No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('input', input, input.name);
|
||||
formData.append('manual', 'true');
|
||||
|
||||
uploadAvatar(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<label
|
||||
htmlFor={'file-upload-avatar'}
|
||||
className="flex h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal transition-colors hover:bg-slate-200 hover:text-green-700 dark:bg-transparent dark:text-white dark:hover:bg-gray-800 dark:hover:text-green-500"
|
||||
>
|
||||
<FileImage className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
<input
|
||||
id={'file-upload-avatar'}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden')}
|
||||
accept=".png, .jpg"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={() => setDialogOpen(false)}>
|
||||
<DialogContent
|
||||
className={cn('shadow-2xl dark:bg-gray-900 dark:text-white md:h-[350px] md:w-[450px] ')}
|
||||
style={{ borderRadius: '12px' }}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-200">
|
||||
{localize('com_ui_preview')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="mb-2 rounded-full"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '150px',
|
||||
width: '150px',
|
||||
height: '150px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
'mt-4 rounded px-4 py-2 text-white hover:bg-green-600 hover:text-gray-200',
|
||||
isUploading ? 'cursor-not-allowed bg-green-600' : 'bg-green-500',
|
||||
)}
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex h-6">
|
||||
<Spinner className="icon-sm m-auto" />
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_upload')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Avatar;
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from 'librechat-data-provider/react-query';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useOnClickOutside } from '~/hooks';
|
||||
import DangerButton from './DangerButton';
|
||||
import DangerButton from '../DangerButton';
|
||||
|
||||
export const RevokeKeysButton = ({
|
||||
showText = true,
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '~/hooks';
|
||||
import type { TDangerButtonProps } from '~/common';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import DangerButton from './DangerButton';
|
||||
import DangerButton from '../DangerButton';
|
||||
import store from '~/store';
|
||||
import { Dropdown } from '~/components/ui';
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { default as General } from './General';
|
||||
export { ClearChatsButton } from './General';
|
||||
export { default as Data } from './Data';
|
||||
export { RevokeKeysButton } from './Data';
|
||||
export { default as General } from './General/General';
|
||||
export { ClearChatsButton } from './General/General';
|
||||
export { default as Data } from './Data/Data';
|
||||
export { RevokeKeysButton } from './Data/Data';
|
||||
export { default as Account } from './Account/Account';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue