Skip to content

Commit

Permalink
Merge pull request #567 from AlexanderKireev/fix-add-avatar-issue
Browse files Browse the repository at this point in the history
Adding an avatar by an authorized user
  • Loading branch information
dzencot authored Jan 3, 2025
2 parents ab927be + a712b20 commit 3fdc1d1
Show file tree
Hide file tree
Showing 15 changed files with 351 additions and 17 deletions.
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@types/bcrypt": "^5.0.0",
"axios": "^1.6.0",
"bcrypt": "^5.1.0",
"body-parser": "^1.20.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
Expand Down
3 changes: 3 additions & 0 deletions backend/src/entities/user-settings.entity.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export class UserSettings {

@Column('text', { default: 'system' })
theme: string;

@Column({ nullable: true })
avatar_base64: string;

@CreateDateColumn()
created_at: string;
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main.ts
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable import/no-import-module-exports */
import { HttpAdapterHost, NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import * as cookieParser from 'cookie-parser';
import { useContainer } from 'class-validator';
import { ValidationPipe } from '@nestjs/common';
Expand All @@ -27,6 +28,7 @@ async function bootstrap() {
app.setGlobalPrefix('api');
app.enable('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.use(cookieParser());
app.use(json({ limit: '500kb' }));
app.useGlobalPipes(new ValidationPipe());

const config = new DocumentBuilder()
Expand Down
5 changes: 5 additions & 0 deletions backend/src/migrations/1730013613468-backend_user_settings.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export class BackendUserSettings1730013613468 implements MigrationInterface {
type: 'varchar(50)',
isNullable: false,
},
{
name: 'avatar_base64',
type: 'text',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
Expand Down
9 changes: 8 additions & 1 deletion backend/src/users/dto/update-user-settings.dto.ts
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { IsString } from 'class-validator';
import { IsString, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class UpdateUserSettingsDto {
@ApiProperty()
@IsOptional()
@IsString()
theme?: string;

@ApiProperty()
@IsOptional()
@IsString()
language?: string;

@ApiProperty()
@IsOptional()
@IsString()
avatar_base64?: string;
}
5 changes: 5 additions & 0 deletions backend/src/users/users.service.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class UsersService {
userId: newUser.id,
theme: 'system',
language: 'ru',
avatar_base64: null,
});
await this.userSettingsRepository.save(userSettings);
return newUser;
Expand All @@ -75,6 +76,7 @@ export class UsersService {
...updatedUser,
language: settings.language,
theme: settings.theme,
avatar_base64: settings.avatar_base64,
};
}

Expand All @@ -96,6 +98,7 @@ export class UsersService {
...currentUser,
language: updateSettings.language,
theme: updateSettings.theme,
avatar_base64: updateSettings.avatar_base64,
};
}

Expand Down Expand Up @@ -202,6 +205,7 @@ export class UsersService {
userId: id,
theme: 'system',
language: 'ru',
avatar_base64: null,
});
await this.userSettingsRepository.save(createSettingsUser);
}
Expand All @@ -212,6 +216,7 @@ export class UsersService {
...currentUser,
language: settings.language,
theme: settings.theme,
avatar_base64: settings.avatar_base64,
};
return { currentUser: data, snippets };
}
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"react-bootstrap-typeahead": "^6.3.1",
"react-dom": "^18.2.0",
"react-i18next": "^13.0.3",
"react-image-file-resizer": "^0.4.8",
"react-redux": "^8.1.2",
"react-resizable-panels": "^0.0.54",
"react-router": "^6.14.2",
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/Forms/AvatarChangeForm.jsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function AvatarChangeForm() {
keyPrefix: 'profileSettings',
});
const dispatch = useDispatch();
const avatar = useSelector((state) => state.userSettings.avatar);
const username = useSelector((state) => state.user.userInfo.username);

const handleEditAvatar = (type) => () => {
Expand All @@ -25,7 +26,17 @@ function AvatarChangeForm() {
className="img-thumbnail rounded-circle overflow-hidden"
style={{ width: '14rem', height: '14rem' }}
>
<Avatar username={username} />
{avatar ? (
<img
alt=""
className="rounded-circle overflow-hidden h-100"
height="100%"
src={avatar}
width="100%"
/>
) : (
<Avatar username={username} />
)}
</div>
<Button
className="position-relative"
Expand All @@ -36,6 +47,7 @@ function AvatarChangeForm() {
{tPS('updateButton')}
</Button>
<Button
disabled={!avatar}
onClick={handleEditAvatar({ type: 'removeAvatar' })}
size="sm"
variant="nofill-secondary"
Expand Down
107 changes: 94 additions & 13 deletions frontend/src/components/Modals/ChangeAvatar.jsx
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,27 +1,105 @@
import { toast } from 'react-toastify';
import { Button, Modal, FormControl, FormLabel, Form } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useRef, useState } from 'react';
import AvatarEditor from 'react-avatar-editor';
import Resizer from 'react-image-file-resizer';
import { useDispatch, useSelector } from 'react-redux';
import { updateUserSettings } from '../../slices/userSettingsSlice';

const resizeFile = (file) =>
new Promise((resolve) => {
Resizer.imageFileResizer(
file,
250,
250,
'JPEG',
100,
0,
(uri) => {
resolve(uri);
},
'base64',
);
});

function ChangeAvatar({ handleClose, isOpen }) {
const dispatch = useDispatch();
const { t: tMCA } = useTranslation('translation', {
keyPrefix: 'modals.changeAvatar',
});
const [avatarState, setAvatarState] = useState({
const initialAvatarState = {
scale: 1,
img: null,
imageChosen: false,
});
isResized: true,
};
const [avatarState, setAvatarState] = useState(initialAvatarState);

const { id } = useSelector((state) => state.user.userInfo);
const { loadingStatus } = useSelector((state) => state.userSettings);
const fileInputRef = useRef(null);
const cropRef = useRef(null);

const handleInputClick = () => fileInputRef.current.click();
const handleLabelClick = (e) => e.stopPropagation();

const handleChangeAvatar = (e) => {
const file = e.target.files[0];
if (!file) {
return;
}
if (
file.type === 'image/png' ||
file.type === 'image/bmp' ||
file.type === 'image/jpeg'
) {
setAvatarState({
...avatarState,
img: file,
});
} else {
toast.error('Неверный формат файла');
setAvatarState(initialAvatarState);
}
};

const handleSaveAvatar = async () => {
if (cropRef) {
setAvatarState({
...avatarState,
isResized: false,
});
const dataUrl = cropRef.current.getImage().toDataURL();
const result = await fetch(dataUrl);
const blob = await result.blob();
const image = await resizeFile(blob);
setAvatarState({
...avatarState,
isResized: true,
});
const data = { avatar_base64: image };
dispatch(updateUserSettings({ id, data })).then((req) => {
if (!req.error) {
handleClose();
setAvatarState(initialAvatarState);
} else {
toast.error('Ошибка сети');
}
});
}
};

const handleCancel = () => {
handleClose();
setAvatarState(initialAvatarState);
};

return (
<Modal centered onHide={handleClose} show={isOpen} size="sm">
<div className="m-2 text-center">
<AvatarEditor
ref={cropRef}
backgroundColor="white"
border={0}
className="rounded-circle"
height={250}
Expand All @@ -38,6 +116,7 @@ function ChangeAvatar({ handleClose, isOpen }) {
onChange={(e) =>
setAvatarState({ ...avatarState, scale: e.target.value / 10 })
}
value={avatarState.scale * 10}
/>
<Button onClick={handleInputClick}>
<FormLabel
Expand All @@ -49,28 +128,30 @@ function ChangeAvatar({ handleClose, isOpen }) {
</FormLabel>
<FormControl
ref={fileInputRef}
accept="image/png, image/jpeg, , image/bmp"
className="form-control d-none"
id="customFile1"
onChange={(e) =>
setAvatarState({
...avatarState,
img: e.target.files[0],
imageChosen: true,
})
}
onChange={(e) => handleChangeAvatar(e)}
type="file"
/>
</Button>
<div>
<p style={{ fontSize: 'small', margin: '5px' }}>
Формат: jpg, png, bmp
</p>
<Button
className="mt-3 me-3"
disabled={!avatarState.imageChosen}
onClick={handleClose}
disabled={
!avatarState.img ||
!avatarState.isResized ||
loadingStatus === 'loading'
}
onClick={handleSaveAvatar}
variant="success"
>
{tMCA('uploadButton')}
</Button>
<Button className="mt-3" onClick={handleClose} variant="secondary">
<Button className="mt-3" onClick={handleCancel} variant="secondary">
{tMCA('cancelButton')}
</Button>
</div>
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/components/Modals/RemoveAvatar.jsx
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { Button, Modal, FormGroup } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useDispatch, useSelector } from 'react-redux';
import { updateUserSettings } from '../../slices/userSettingsSlice';

function RemoveAvatar({ handleClose, isOpen }) {
const { t: tMRA } = useTranslation('translation', {
keyPrefix: 'modals.removeAvatar',
});
const dispatch = useDispatch();
const { id } = useSelector((state) => state.user.userInfo);
const { loadingStatus } = useSelector((state) => state.userSettings);

const handleDeleteAvatar = async () => {
const data = { avatar_base64: null };
dispatch(updateUserSettings({ id, data })).then((req) => {
if (!req.error) {
handleClose();
} else {
toast.error('Ошибка сети');
}
});
};

return (
<Modal centered onHide={handleClose} show={isOpen} size="m">
Expand All @@ -13,7 +30,12 @@ function RemoveAvatar({ handleClose, isOpen }) {
<p>{tMRA('message')}</p>
</div>
<FormGroup className="d-flex justify-content-center">
<Button className="me-5 px-4" onClick={handleClose} variant="danger">
<Button
className="me-5 px-4"
disabled={loadingStatus === 'loading'}
onClick={handleDeleteAvatar}
variant="danger"
>
{tMRA('removeButton')}
</Button>
<Button className="px-4" onClick={handleClose} variant="secondary">
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/Navigation/UserMenu.jsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function UserMenu() {
});
const dispatch = useDispatch();
const username = useSelector((state) => state.user.userInfo.username);
const avatar = useSelector((state) => state.userSettings.avatar);

const handleNewSnippet = () => {
dispatch(actions.openModal({ type: 'newSnippet' }));
Expand All @@ -34,7 +35,17 @@ function UserMenu() {
variant="link"
>
<div className="logo-height">
<Avatar username={username} />
{avatar ? (
<img
alt=""
className="rounded-circle overflow-hidden h-100"
height="100%"
src={avatar}
width="100%"
/>
) : (
<Avatar username={username} />
)}
</div>
<span className="visually-hidden">{tPA('header')}</span>
</Dropdown.Toggle>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/profile/index.jsx
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Row from 'react-bootstrap/Row';
import { CSSTransition, TransitionGroup } from 'react-transition-group';

import { fetchUserSnippets } from '../../slices/snippetsSlice.js';
import { fetchUserSettings } from '../../slices/userSettingsSlice';

import NotFoundPage from '../404';
import NewSnippetForm from './NewSnippetForm.jsx';
Expand Down Expand Up @@ -67,6 +68,10 @@ function ProfilePage() {
});
}, [dispatch]);

useEffect(() => {
dispatch(fetchUserSettings());
}, [dispatch]);

// TODO: добавить возможность получать сниппеты другого пользователя, когда появится возможность делится профилем
return isMyProfile ? (
<ProfileLayout
Expand Down
Loading

0 comments on commit 3fdc1d1

Please sign in to comment.