create base components

This commit is contained in:
Timofey
2025-08-29 14:01:39 +03:00
parent 691315da0d
commit ffe77d1ce0
15 changed files with 1577 additions and 160 deletions

9
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
dist
out
coverage
public
.vscode
*.log
*.lock

9
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "avoid",
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -0,0 +1,7 @@
import React from 'react'
const LoginPage = () => {
return <div> LoginPage</div>
}
export default LoginPage

View File

@@ -0,0 +1,9 @@
import React from 'react'
const page = () => {
return (
<div>page</div>
)
}
export default page

View File

@@ -0,0 +1,9 @@
import React from 'react'
const page = () => {
return (
<div>page</div>
)
}
export default page

View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'
import axios, { AxiosError, AxiosRequestConfig } from 'axios'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
interface FetchOptions<TData, TVariables, TError> {
method?: HttpMethod
config?: AxiosRequestConfig
queryOptions?: Omit<UseQueryOptions<TData, TError>, 'queryKey' | 'queryFn'>
mutationOptions?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
}
interface QueryResult<TData, TError> {
data: TData | undefined
isLoading: boolean
error: TError | null
refetch: () => Promise<unknown>
}
interface MutationResult<TData, TVariables, TError> {
mutate: (variables: TVariables) => void
isLoading: boolean
error: TError | null
data: TData | undefined
}
export function useClientFetch<TData = unknown, TVariables = void, TError = AxiosError>(
url: string,
options: FetchOptions<TData, TVariables, TError> = {}
): TVariables extends void
? QueryResult<TData, TError>
: MutationResult<TData, TVariables, TError> {
const { method = 'GET', config = {}, queryOptions = {}, mutationOptions = {} } = options
const API_URL = process.env.NEXT_PUBLIC_API_URL
const fullUrl = url.startsWith('http') ? url : `${API_URL}${url}`
// всегда вызываем оба хука
const query = useQuery<TData, TError>({
queryKey: [url, config.params],
queryFn: async () => {
const response = await axios.get(fullUrl, config)
return response.data
},
...queryOptions,
// отключаем автоматическое выполнение для мутаций
enabled: method === 'GET' && queryOptions.enabled !== false,
})
const mutation = useMutation<TData, TError, TVariables>({
mutationFn: async (variables: TVariables) => {
const response = await axios({
method: method.toLowerCase(),
url: fullUrl,
data: variables,
...config,
})
return response.data
},
...mutationOptions,
})
// возвращаем соответствующий результат в зависимости от метода
if (method === 'GET') {
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
} as TVariables extends void ? QueryResult<TData, TError> : never
}
return {
mutate: mutation.mutate,
isLoading: mutation.isPending,
error: mutation.error,
data: mutation.data,
} as TVariables extends void ? never : MutationResult<TData, TVariables, TError>
}
// примеры использования:
/*
// GET запрос
interface UserData {
id: number
name: string
email: string
}
const { data, isLoading, error } = useClientFetch<UserData>('/users/me')
// POST запрос с типизированным payload
interface LoginPayload {
email: string
password: string
}
interface LoginResponse {
token: string
user: UserData
}
const { mutate, isLoading } = useClientFetch<LoginResponse, LoginPayload>('/auth/login', {
method: 'POST',
mutationOptions: {
onSuccess: (data) => {
// data типизирован как LoginResponse
},
onError: (error) => {
// error типизирован как AxiosError
}
}
})
// использование:
mutate({ email: 'user@example.com', password: '123456' })
// PATCH запрос
interface UpdateUserPayload {
name?: string
email?: string
}
const { mutate } = useClientFetch<UserData, UpdateUserPayload>('/users/me', {
method: 'PATCH'
})
// использование:
mutate({ name: 'New Name' })
*/

View File

@@ -0,0 +1,150 @@
'use client'
import { useState, ChangeEvent, FormEvent } from 'react'
import toast from 'react-hot-toast'
import { ValidationRules, ValidationErrors } from '../types'
type FormValue = string | number | boolean | string[] | number[] | null
type CustomChangeEvent = {
target: {
id: string
value: FormValue
type?: string
checked?: boolean
}
}
export function useForm<T extends Record<string, FormValue>>(
initialValues: T,
validationRules?: { [K in keyof T]?: ValidationRules },
onSubmit?: (values: T) => void
) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<ValidationErrors>({})
const [isVisible, setIsVisible] = useState(false)
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | CustomChangeEvent
) => {
const { id, value, type } = e.target
const isCheckbox = type === 'checkbox' && 'checked' in e.target
setValues(prev => ({
...prev,
[id]: isCheckbox ? (e.target as HTMLInputElement).checked : value,
}))
// скидываем ошибки
if (errors[id]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[id]
return newErrors
})
}
}
const resetField = (fieldName: keyof T, value: FormValue = '') => {
setValues(prev => ({
...prev,
[fieldName]: value,
}))
// очищаем ошибки для этого поля, если они есть
if (errors[fieldName as string]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[fieldName as string]
return newErrors
})
}
}
const fieldNames: { [key: string]: string } = {
login: 'Логин',
password: 'Пароль',
accountType: 'Роль',
}
const validate = () => {
if (!validationRules) return true
const newErrors: ValidationErrors = {}
Object.keys(validationRules).forEach(key => {
const value = values[key]
const rules = validationRules[key as keyof T]
if (rules?.required && !value) {
newErrors[key] = 'Это поле обязательно'
toast.error(`Поле "${fieldNames[key] || key}" обязательно для заполнения`, {
duration: 2000,
position: 'top-right',
style: {
background: '#FEE2E2',
color: '#991B1B',
padding: '16px',
borderRadius: '8px',
},
})
}
if (rules?.minLength && typeof value === 'string' && value.length < rules.minLength) {
newErrors[key] = `Минимальная длина ${rules.minLength} символов`
toast.error(
`Минимальная длина поля "${fieldNames[key] || key}" - ${rules.minLength} символов`,
{
duration: 2000,
position: 'top-right',
style: {
background: '#FEE2E2',
color: '#991B1B',
padding: '16px',
borderRadius: '8px',
},
}
)
}
if (rules?.pattern && typeof value === 'string' && !rules.pattern.test(value)) {
newErrors[key] = 'Неверный формат'
toast.error(`Поле "${fieldNames[key] || key}" заполнено некорректно`, {
duration: 2000,
position: 'top-right',
style: {
background: '#FEE2E2',
color: '#991B1B',
padding: '16px',
borderRadius: '8px',
},
})
}
})
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (validate() && onSubmit) {
onSubmit(values)
}
}
const togglePasswordVisibility = () => {
setIsVisible(!isVisible)
}
return {
values,
errors,
isVisible,
setValues,
handleChange,
handleSubmit,
togglePasswordVisibility,
resetField,
}
}

View File

@@ -0,0 +1,9 @@
export interface ValidationRules {
required?: boolean
minLength?: number
pattern?: RegExp
}
export interface ValidationErrors {
[key: string]: string
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
const Selector = () => {
return (
<div>Selector</div>
)
}
export default Selector

View File

@@ -0,0 +1,9 @@
import React from 'react'
const TextInput = () => {
return (
<div>TextInput</div>
)
}
export default TextInput

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,15 @@
"dependencies": {
"@babylonjs/core": "^6.44.0",
"@babylonjs/loaders": "^6.49.0",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
"next": "^15.4.3",
"next-auth": "^4.24.11",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-select": "^5.10.2",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -23,6 +29,8 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.3",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4",
"typescript": "^5"
}