mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
Add user profile image
This commit is contained in:
@@ -134,7 +134,16 @@ export type RelatedServerPermission = {
|
||||
type: ServerPermissionType;
|
||||
};
|
||||
|
||||
export type ServerFile = {
|
||||
id: string;
|
||||
mimetype: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
avatar: ServerFile | null;
|
||||
createdAt: string;
|
||||
displayName?: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FileWithPath } from '@mantine/dropzone';
|
||||
import { BaseResponse, NullResponse, User } from '@/renderer/api/types';
|
||||
import { ax } from '@/renderer/lib/axios';
|
||||
|
||||
@@ -15,6 +16,8 @@ const getUserList = async (signal?: AbortSignal) => {
|
||||
};
|
||||
|
||||
export type CreateUserBody = {
|
||||
displayName?: string;
|
||||
image?: FileWithPath;
|
||||
password: string;
|
||||
username: string;
|
||||
};
|
||||
@@ -32,9 +35,25 @@ const deleteUser = async (query: { userId: string }) => {
|
||||
export type UpdateUserBody = Partial<CreateUserBody>;
|
||||
|
||||
const updateUser = async (query: { userId: string }, body: UpdateUserBody) => {
|
||||
if (body.image) {
|
||||
const formData = new FormData();
|
||||
formData.append('image', body.image);
|
||||
if (body.username) formData.append('username', body.username);
|
||||
if (body.displayName) formData.append('displayName', body.displayName);
|
||||
|
||||
const { data } = await ax.patch<UserDetailResponse>(
|
||||
`/users/${query.userId}`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const { data } = await ax.patch<UserDetailResponse>(
|
||||
`/users/${query.userId}`,
|
||||
body
|
||||
body,
|
||||
{}
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ export const AddUserForm = ({ onCancel }: AddUserFormProps) => {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleAddUser}>
|
||||
<Stack ref={focusTrapRef}>
|
||||
<Stack ref={focusTrapRef} m={5}>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
required
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Stack, Group } from '@mantine/core';
|
||||
import { Stack, Group, Grid, Image } from '@mantine/core';
|
||||
import { FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useClipboard, useFocusTrap } from '@mantine/hooks';
|
||||
import { RiImage2Line } from 'react-icons/ri';
|
||||
import { User } from '@/renderer/api/types';
|
||||
import {
|
||||
Button,
|
||||
@@ -8,6 +10,8 @@ import {
|
||||
TextInput,
|
||||
Switch,
|
||||
toast,
|
||||
Dropzone,
|
||||
Text,
|
||||
} from '@/renderer/components';
|
||||
import { usePermissions } from '@/renderer/features/shared';
|
||||
import { useUpdateUser } from '@/renderer/features/users/mutations/update-user';
|
||||
@@ -31,6 +35,7 @@ export const EditUserForm = ({
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
displayName: user?.displayName || '',
|
||||
image: null as FileWithPath | null,
|
||||
isAdmin: user?.isAdmin || false,
|
||||
password: '',
|
||||
repeatPassword: '',
|
||||
@@ -58,6 +63,7 @@ export const EditUserForm = ({
|
||||
const body = {
|
||||
...values,
|
||||
displayName: values.displayName || undefined,
|
||||
image: values.image || undefined,
|
||||
password: values.password || undefined,
|
||||
repeatPassword: values.repeatPassword || undefined,
|
||||
};
|
||||
@@ -81,62 +87,126 @@ export const EditUserForm = ({
|
||||
);
|
||||
});
|
||||
|
||||
const getPreview = () => {
|
||||
if (form.values.image instanceof File) {
|
||||
const imageUrl = URL.createObjectURL(form.values.image);
|
||||
return (
|
||||
<Image
|
||||
height={150}
|
||||
imageProps={{ onLoad: () => URL.revokeObjectURL(imageUrl) }}
|
||||
radius={100}
|
||||
src={imageUrl}
|
||||
sx={{ objectFit: 'contain' }}
|
||||
width={150}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => form.setFieldValue('image', null);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleUpdateUser}>
|
||||
<Stack ref={focusTrapRef} spacing="xl">
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Username"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Display name"
|
||||
{...form.getInputProps('displayName')}
|
||||
/>
|
||||
<PasswordInput label="Password" {...form.getInputProps('password')} />
|
||||
{repeatPassword && (
|
||||
<PasswordInput
|
||||
label="Repeat password"
|
||||
{...form.getInputProps('repeatPassword')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group position="apart">
|
||||
{permissions.isAdmin && !user?.isSuperAdmin ? (
|
||||
<Group>
|
||||
Admin
|
||||
<Switch
|
||||
{...form.getInputProps('isAdmin', { type: 'checkbox' })}
|
||||
<Stack ref={focusTrapRef} spacing="xs" sx={{ margin: '5px' }}>
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
<Stack spacing="xs" sx={{ height: '100%' }}>
|
||||
<Dropzone
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
maxSize={3 * 1024 ** 2}
|
||||
multiple={false}
|
||||
onDrop={(file) => form.setFieldValue('image', file[0])}
|
||||
onReject={(err) =>
|
||||
toast.error({
|
||||
message: `${err[0].errors[0].message}`,
|
||||
title: 'Invalid Image',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Group>
|
||||
<Dropzone.Idle>
|
||||
{form.values.image ? (
|
||||
<Group position="center">{getPreview()}</Group>
|
||||
) : (
|
||||
<Group position="center">
|
||||
<RiImage2Line color="var(--primary-color)" size={30} />
|
||||
<Stack spacing="xs">
|
||||
<Text>Profile image</Text>
|
||||
<Text>Max size: 5MB</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
<Button compact variant="subtle" onClick={handleRemoveImage}>
|
||||
Remove image
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
data-autofocus
|
||||
label="Username"
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Group />
|
||||
)}
|
||||
{!repeatPassword && (
|
||||
<Button
|
||||
compact
|
||||
sx={{ height: '1.5rem' }}
|
||||
variant="subtle"
|
||||
onClick={handleGeneratePassword}
|
||||
>
|
||||
Generate password
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
{...form.getInputProps('displayName')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{repeatPassword && (
|
||||
<PasswordInput
|
||||
label="Repeat Password"
|
||||
{...form.getInputProps('repeatPassword')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group mt={10} position="right">
|
||||
<Button variant="subtle" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isSubmitValid()}
|
||||
loading={updateUserMutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
{permissions.isAdmin && !user?.isSuperAdmin ? (
|
||||
<Group>
|
||||
Admin
|
||||
<Switch
|
||||
{...form.getInputProps('isAdmin', { type: 'checkbox' })}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Group />
|
||||
)}
|
||||
{!repeatPassword && (
|
||||
<Button
|
||||
compact
|
||||
sx={{ height: '1.5rem' }}
|
||||
variant="subtle"
|
||||
onClick={handleGeneratePassword}
|
||||
>
|
||||
Generate password
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group mt={10} position="right">
|
||||
<Button variant="subtle" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!isSubmitValid()}
|
||||
loading={updateUserMutation.isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ export const UserList = () => {
|
||||
},
|
||||
modal: 'base',
|
||||
overflow: 'inside',
|
||||
size: 'lg',
|
||||
title: `Edit User`,
|
||||
transition: 'slide-down',
|
||||
});
|
||||
@@ -110,7 +111,7 @@ export const UserList = () => {
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<Avatar radius="xl" />
|
||||
<Avatar radius="xl" src={u.avatarUrl} />
|
||||
<Stack spacing="xs">
|
||||
<Text overflow="hidden">
|
||||
{u.username}
|
||||
|
||||
@@ -3,11 +3,23 @@ import { api } from '@/renderer/api';
|
||||
import { queryKeys } from '@/renderer/api/query-keys';
|
||||
import { UserListResponse } from '@/renderer/api/users.api';
|
||||
import { QueryOptions } from '@/renderer/lib/react-query';
|
||||
import { useAuthStore } from '@/renderer/store';
|
||||
import { getFileUrl } from '@/renderer/utils';
|
||||
|
||||
export const useUserList = (options?: QueryOptions<UserListResponse>) => {
|
||||
const serverUrl = useAuthStore((state) => state.serverUrl);
|
||||
|
||||
const query = useQuery({
|
||||
queryFn: () => api.users.getUserList(),
|
||||
queryKey: queryKeys.users.list(),
|
||||
select: (data) => {
|
||||
const users = data.data.map((user) => ({
|
||||
...user,
|
||||
avatarUrl: getFileUrl(serverUrl, user?.avatar),
|
||||
}));
|
||||
|
||||
return { ...data, data: users };
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ServerFile } from '@/renderer/api/types';
|
||||
|
||||
export const getFileUrl = (serverUrl: string, file: ServerFile | null) => {
|
||||
if (!file) return undefined;
|
||||
return `${serverUrl}/${file.path}`;
|
||||
};
|
||||
@@ -6,3 +6,4 @@ export * from './server-folder-auth';
|
||||
export * from './set-local-storage-setttings';
|
||||
export * from './constrain-sidebar-width';
|
||||
export * from './title-case';
|
||||
export * from './get-file-url';
|
||||
|
||||
Reference in New Issue
Block a user