create base components
This commit is contained in:
9
frontend/.prettierignore
Normal file
9
frontend/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
out
|
||||
coverage
|
||||
public
|
||||
.vscode
|
||||
*.log
|
||||
*.lock
|
||||
9
frontend/.prettierrc
Normal file
9
frontend/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "avoid",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
7
frontend/app/(auth)/login/page.tsx
Normal file
7
frontend/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
const LoginPage = () => {
|
||||
return <div> LoginPage</div>
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
9
frontend/app/(protected)/dashboard/page.tsx
Normal file
9
frontend/app/(protected)/dashboard/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>page</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
9
frontend/app/(protected)/objects/page.tsx
Normal file
9
frontend/app/(protected)/objects/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>page</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
131
frontend/app/hooks/useClientFetch.ts
Normal file
131
frontend/app/hooks/useClientFetch.ts
Normal 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' })
|
||||
*/
|
||||
150
frontend/app/hooks/useForm.ts
Normal file
150
frontend/app/hooks/useForm.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
9
frontend/app/types/index.ts
Normal file
9
frontend/app/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface ValidationRules {
|
||||
required?: boolean
|
||||
minLength?: number
|
||||
pattern?: RegExp
|
||||
}
|
||||
|
||||
export interface ValidationErrors {
|
||||
[key: string]: string
|
||||
}
|
||||
9
frontend/components/ui/Selector.tsx
Normal file
9
frontend/components/ui/Selector.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
const Selector = () => {
|
||||
return (
|
||||
<div>Selector</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
9
frontend/components/ui/TextInput.tsx
Normal file
9
frontend/components/ui/TextInput.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
const TextInput = () => {
|
||||
return (
|
||||
<div>TextInput</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInput
|
||||
1226
frontend/package-lock.json
generated
1226
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user