new features

This commit is contained in:
Daniil
2026-02-27 23:34:17 +03:00
parent 42ce5fa0fe
commit 71b974903a
191 changed files with 11300 additions and 373 deletions
+5
View File
@@ -0,0 +1,5 @@
export interface IAvatarUploadProps {
currentAvatarUrl: string | null
onAvatarChange: (url: string) => void
className?: string
}
@@ -0,0 +1,38 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatarButton {
all: unset;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.changeLink {
all: unset;
cursor: pointer;
color: var(--accent-9);
font-size: 14px;
&:hover {
text-decoration: underline;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.fileInput {
display: none;
}
@@ -0,0 +1,81 @@
"use client"
import type { IAvatarUploadProps } from "./AvatarUpload.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useRef, useState } from "react"
import { uploadFileWithProgress } from "@shared/api/uploadFile"
import { Avatar } from "@shared/ui/Avatar"
import styles from "./AvatarUpload.module.scss"
export const AvatarUpload: FunctionComponent<
IAvatarUploadProps
> = ({ currentAvatarUrl, onAvatarChange, className }): JSX.Element => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const handleClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0]
if (!file) return
setUploadProgress(0)
try {
const result = await uploadFileWithProgress(
file,
"avatars",
setUploadProgress,
)
onAvatarChange(result.file_path)
} catch (err) {
console.error("Avatar upload failed:", err)
} finally {
setUploadProgress(null)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
return (
<div
className={cs(styles.root, className)}
data-testid="AvatarUpload"
>
<button
type="button"
className={styles.avatarButton}
onClick={handleClick}
disabled={uploadProgress !== null}
>
<Avatar
size="xlarge"
url={currentAvatarUrl || ""}
/>
</button>
<button
type="button"
className={styles.changeLink}
onClick={handleClick}
disabled={uploadProgress !== null}
>
{uploadProgress !== null
? `Загрузка ${uploadProgress}%`
: "Изменить фото"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className={styles.fileInput}
onChange={handleFileChange}
/>
</div>
)
}
@@ -0,0 +1 @@
export * from "./AvatarUpload"
@@ -0,0 +1,3 @@
export interface IChangePasswordFormProps {
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,108 @@
"use client"
import type { IChangePasswordFormProps } from "./ChangePasswordForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./ChangePasswordForm.module.scss"
interface IPasswordFormData {
current_password: string
new_password: string
confirm_password: string
}
export const ChangePasswordForm: FunctionComponent<
IChangePasswordFormProps
> = ({ className }): JSX.Element => {
const [successMessage, setSuccessMessage] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const { register, handleSubmit, reset, formState: { errors } } = useForm<IPasswordFormData>({
defaultValues: {
current_password: "",
new_password: "",
confirm_password: "",
},
})
const { mutate, isPending } = api.useMutation(
"post",
"/api/users/me/change-password/",
{
onSuccess: () => {
setErrorMessage(null)
setSuccessMessage(true)
reset()
setTimeout(() => setSuccessMessage(false), 3000)
},
onError: (error) => {
setSuccessMessage(false)
setErrorMessage(
(error as { detail?: string })?.detail || "Не удалось сменить пароль",
)
},
},
)
const onSubmit = (data: IPasswordFormData) => {
setErrorMessage(null)
mutate({
body: {
current_password: data.current_password,
new_password: data.new_password,
},
})
}
return (
<div className={className} data-testid="ChangePasswordForm">
<h3 className={styles.title}>Смена пароля</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="current_password"
label="Текущий пароль"
placeholder="Введите текущий пароль"
type="password"
{...register("current_password", { required: "Обязательное поле" })}
/>
<TextField
id="new_password"
label="Новый пароль"
placeholder="Введите новый пароль"
type="password"
{...register("new_password", { required: "Обязательное поле" })}
/>
<TextField
id="confirm_password"
label="Подтверждение пароля"
placeholder="Повторите новый пароль"
type="password"
error={!!errors.confirm_password}
{...register("confirm_password", {
required: "Обязательное поле",
validate: (value, formValues) =>
value === formValues.new_password || "Пароли не совпадают",
})}
/>
{errors.confirm_password && (
<Alert variant="danger">{errors.confirm_password.message}</Alert>
)}
</div>
{successMessage && (
<Alert variant="success">Пароль успешно изменён</Alert>
)}
{errorMessage && <Alert variant="danger">{errorMessage}</Alert>}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сменить пароль"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./ChangePasswordForm"
@@ -0,0 +1,6 @@
import type { components } from "@shared/api/__generated__/openapi.types"
export interface IEditProfileFormProps {
user: components["schemas"]["UserRead"]
className?: string
}
@@ -0,0 +1,17 @@
.title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.fields {
display: flex;
flex-direction: column;
gap: 12px;
}
@@ -0,0 +1,102 @@
"use client"
import type { IEditProfileFormProps } from "./EditProfileForm.d"
import type { JSX } from "react"
import { FunctionComponent, useState } from "react"
import { useForm } from "react-hook-form"
import api from "@shared/api"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { setUser } from "@shared/store/user"
import { Alert, Button, Form, TextField } from "@shared/ui"
import styles from "./EditProfileForm.module.scss"
interface IProfileFormData {
first_name: string
last_name: string
email: string
phone_number: string
}
export const EditProfileForm: FunctionComponent<
IEditProfileFormProps
> = ({ user, className }): JSX.Element => {
const dispatch = useAppDispatch()
const [successMessage, setSuccessMessage] = useState(false)
const { register, handleSubmit } = useForm<IProfileFormData>({
defaultValues: {
first_name: user.first_name || "",
last_name: user.last_name || "",
email: user.email || "",
phone_number: user.phone_number || "",
},
})
const { mutate, isPending } = api.useMutation(
"patch",
"/api/users/{user_id}/",
{
onSuccess: (data) => {
dispatch(setUser(data))
setSuccessMessage(true)
setTimeout(() => setSuccessMessage(false), 3000)
},
},
)
const onSubmit = (data: IProfileFormData) => {
mutate({
params: { path: { user_id: user.id } },
body: {
first_name: data.first_name || null,
last_name: data.last_name || null,
email: data.email || null,
phone_number: data.phone_number || null,
},
})
}
return (
<div className={className} data-testid="EditProfileForm">
<h3 className={styles.title}>Личная информация</h3>
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="first_name"
label="Имя"
placeholder="Ваше имя"
{...register("first_name")}
/>
<TextField
id="last_name"
label="Фамилия"
placeholder="Ваша фамилия"
{...register("last_name")}
/>
<TextField
id="email"
label="Email"
placeholder="Ваш email"
type="email"
{...register("email")}
/>
<TextField
id="phone_number"
label="Телефон"
placeholder="Ваш телефон"
{...register("phone_number")}
/>
</div>
{successMessage && (
<Alert variant="success">Данные успешно сохранены</Alert>
)}
<Button type="submit" variant="primary" disabled={isPending}>
{isPending ? "Сохранение..." : "Сохранить"}
</Button>
</Form>
</div>
)
}
@@ -0,0 +1 @@
export * from "./EditProfileForm"
+3
View File
@@ -0,0 +1,3 @@
export interface ILogoutButtonProps {
className?: string
}
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,43 @@
"use client"
import type { ILogoutButtonProps } from "./LogoutButton.d"
import type { JSX } from "react"
import Cookies from "js-cookie"
import { FunctionComponent } from "react"
import { useRouter } from "next/navigation"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
} from "@shared/lib/constants"
import { resetUser } from "@shared/store/user"
import { Button } from "@shared/ui"
export const LogoutButton: FunctionComponent<
ILogoutButtonProps
> = ({ className }): JSX.Element => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleLogout = () => {
Cookies.remove(ACCESS_TOKEN_COOKIE)
Cookies.remove(REFRESH_TOKEN_COOKIE)
dispatch(resetUser())
router.push("/login")
}
return (
<Button
variant="danger"
size="lg"
className={className}
onClick={handleLogout}
data-testid="LogoutButton"
>
Выйти из аккаунта
</Button>
)
}
@@ -0,0 +1 @@
export * from "./LogoutButton"
+4
View File
@@ -0,0 +1,4 @@
export { AvatarUpload } from "./AvatarUpload"
export { ChangePasswordForm } from "./ChangePasswordForm"
export { EditProfileForm } from "./EditProfileForm"
export { LogoutButton } from "./LogoutButton"