feat / AEB-26 login page
This commit is contained in:
78
frontend/components/selectors/RoleSelector.tsx
Normal file
78
frontend/components/selectors/RoleSelector.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react'
|
||||
import Selector, { Option } from '@/components/ui/Selector'
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
value: string
|
||||
label: string
|
||||
[key: string]: string | number
|
||||
}
|
||||
|
||||
interface RoleSelectorProps {
|
||||
name: string
|
||||
value: string
|
||||
handleChange: (e: {
|
||||
target: {
|
||||
id: string
|
||||
value: string
|
||||
selectedOption?: Option
|
||||
}
|
||||
}) => void
|
||||
label?: string
|
||||
tooltip?: string | React.ReactNode
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const ROLES = [
|
||||
{ id: 1, value: 'engineer', label: 'Инженер' },
|
||||
{ id: 2, value: 'operator', label: 'Оператор' },
|
||||
{ id: 3, value: 'administrator', label: 'Администратор' },
|
||||
]
|
||||
|
||||
const RoleSelector: React.FC<RoleSelectorProps> = ({
|
||||
name,
|
||||
value,
|
||||
handleChange,
|
||||
label,
|
||||
tooltip,
|
||||
placeholder,
|
||||
}) => {
|
||||
const handleSelectorChange = (e: {
|
||||
target: {
|
||||
id: string
|
||||
value: Option | null
|
||||
selectedOption?: Option
|
||||
}
|
||||
}) => {
|
||||
const selectedOption = e.target.value
|
||||
handleChange({
|
||||
target: {
|
||||
id: name,
|
||||
value: selectedOption?.value || '',
|
||||
selectedOption: selectedOption || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Находим текущее значение в списке для отображения
|
||||
const currentValue = ROLES.find(role => role.value === value)
|
||||
|
||||
return (
|
||||
<Selector<Role>
|
||||
name={name}
|
||||
value={currentValue}
|
||||
handleChange={handleSelectorChange}
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
placeholder={placeholder}
|
||||
mapDataToOptions={role => ({
|
||||
id: role.id,
|
||||
value: role.value,
|
||||
label: role.label,
|
||||
})}
|
||||
staticOptions={ROLES}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoleSelector
|
||||
34
frontend/components/ui/Button.tsx
Normal file
34
frontend/components/ui/Button.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { ButtonProps } from '@/app/types'
|
||||
|
||||
const Button = ({
|
||||
onClick,
|
||||
className,
|
||||
text,
|
||||
type,
|
||||
leftIcon,
|
||||
midIcon,
|
||||
rightIcon,
|
||||
size = 'lg',
|
||||
}: ButtonProps) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-10 text-sm',
|
||||
md: 'h-12 text-base',
|
||||
lg: 'h-14 text-xl',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer rounded-xl transition-all duration-500 hover:shadow-2xl ${sizeClasses[size]} ${className}`}
|
||||
type={type}
|
||||
>
|
||||
{leftIcon && <span className="mr-2 flex items-center">{leftIcon}</span>}
|
||||
{midIcon && <span className="flex items-center">{midIcon}</span>}
|
||||
<span className="text-center font-normal">{text}</span>
|
||||
{rightIcon && <span className="ml-2 flex items-center">{rightIcon}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
21
frontend/components/ui/Loader.tsx
Normal file
21
frontend/components/ui/Loader.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
const Loader = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="relative h-12 w-12">
|
||||
{/* пульсирующие круги */}
|
||||
<div className="bg-blue/20 absolute inset-0 animate-ping rounded-full"></div>
|
||||
<div
|
||||
className="bg-blue/40 absolute inset-2 animate-ping rounded-full"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
<div className="bg-blue absolute inset-4 animate-pulse rounded-full"></div>
|
||||
{/* бегущий блик */}
|
||||
<div className="bg-blue-to-r animate-shimmer absolute inset-0 rounded-full from-transparent via-white/20 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loader
|
||||
@@ -1,9 +1,190 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import Select from 'react-select'
|
||||
import Tooltip from './Tooltip'
|
||||
import { useClientFetch } from '@/app/hooks/useClientFetch'
|
||||
|
||||
export interface Option {
|
||||
id: number
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DataItem {
|
||||
id: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
results: T[]
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
}
|
||||
|
||||
export interface SelectorProps<T extends DataItem> {
|
||||
name: string
|
||||
value?: Option | null
|
||||
handleChange: (e: {
|
||||
target: {
|
||||
id: string
|
||||
value: Option | null
|
||||
selectedOption?: Option
|
||||
}
|
||||
}) => void
|
||||
label?: string
|
||||
tooltip?: string | React.ReactNode
|
||||
placeholder?: string
|
||||
endpoint?: string
|
||||
mapDataToOptions: (data: T) => Option
|
||||
searchParam?: string
|
||||
staticOptions?: T[]
|
||||
config?: {
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
queryOptions?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Selector = <T extends DataItem>({
|
||||
name,
|
||||
value,
|
||||
handleChange,
|
||||
label,
|
||||
tooltip,
|
||||
placeholder,
|
||||
endpoint,
|
||||
mapDataToOptions,
|
||||
searchParam = 'search',
|
||||
staticOptions,
|
||||
config = {},
|
||||
}: SelectorProps<T>) => {
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const { data, isLoading, error } = useClientFetch<PaginatedResponse<T>>(
|
||||
endpoint || '/404-no-endpoint',
|
||||
{
|
||||
config: {
|
||||
params: {
|
||||
...(search ? { [searchParam]: search } : {}),
|
||||
...(config.params || {}),
|
||||
},
|
||||
},
|
||||
queryOptions: {
|
||||
staleTime: 60 * 60 * 1000, // кешируем на час
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !staticOptions && endpoint !== undefined && config.queryOptions?.enabled !== false, // отключаем запрос если используем статические опции, нет endpoint или явно выключен в конфиге
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
let options: Option[] = []
|
||||
|
||||
if (staticOptions) {
|
||||
// если есть статические опции используем их
|
||||
options = staticOptions.map(mapDataToOptions)
|
||||
} else {
|
||||
// иначе используем данные с бекенда
|
||||
const dataArray = Array.isArray(data) ? data : data?.results || []
|
||||
options = dataArray.map(mapDataToOptions)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error(`Error fetching data from ${endpoint}:`, error)
|
||||
}
|
||||
|
||||
const Selector = () => {
|
||||
return (
|
||||
<div>Selector</div>
|
||||
<div>
|
||||
{label && (
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
{tooltip && <Tooltip content={tooltip} />}
|
||||
</div>
|
||||
)}
|
||||
<Select<Option>
|
||||
inputId={name}
|
||||
name={name}
|
||||
options={options}
|
||||
value={options.find(opt => opt.id === value?.id)}
|
||||
onChange={selectedOption => {
|
||||
handleChange({
|
||||
target: {
|
||||
id: name,
|
||||
value: selectedOption
|
||||
? {
|
||||
...selectedOption,
|
||||
}
|
||||
: null,
|
||||
selectedOption: selectedOption || undefined,
|
||||
},
|
||||
})
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
onInputChange={newValue => setSearch(newValue)}
|
||||
isSearchable
|
||||
isClearable
|
||||
placeholder={placeholder}
|
||||
noOptionsMessage={() => (isLoading ? 'Загрузка...' : 'Нет доступных вариантов')}
|
||||
classNamePrefix="select"
|
||||
className="rounded-lg"
|
||||
styles={{
|
||||
control: base => ({
|
||||
...base,
|
||||
borderRadius: '0.75rem',
|
||||
backgroundColor: '#1B1E28',
|
||||
border: '1px solid #E5E7EB',
|
||||
padding: '2px',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
'&:focus-within': {
|
||||
backgroundColor: '#1B1E28',
|
||||
borderColor: '#E5E7EB',
|
||||
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)',
|
||||
},
|
||||
}),
|
||||
singleValue: base => ({
|
||||
...base,
|
||||
color: 'white',
|
||||
}),
|
||||
input: base => ({
|
||||
...base,
|
||||
color: 'white',
|
||||
}),
|
||||
menu: base => ({
|
||||
...base,
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
zIndex: 9999,
|
||||
marginTop: '4px',
|
||||
borderRadius: '0.75rem',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1B1E28',
|
||||
border: '1px solid #E5E7EB',
|
||||
}),
|
||||
option: (base, state) => ({
|
||||
...base,
|
||||
fontSize: '0.875rem',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: state.isSelected ? '#2563EB' : state.isFocused ? '#2D3139' : '#1B1E28',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
'&:active': {
|
||||
backgroundColor: '#2D3139',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: state.isSelected ? '#2563EB' : '#2D3139',
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Selector
|
||||
export default Selector
|
||||
|
||||
@@ -1,9 +1,76 @@
|
||||
import React from 'react'
|
||||
import { TextInputProps } from '@/app/types'
|
||||
import { HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi'
|
||||
|
||||
const TextInput = ({
|
||||
value,
|
||||
handleChange,
|
||||
label,
|
||||
placeholder,
|
||||
name,
|
||||
type = 'text',
|
||||
className = '',
|
||||
maxLength,
|
||||
min,
|
||||
tooltip,
|
||||
style,
|
||||
isPassword,
|
||||
togglePasswordVisibility,
|
||||
isVisible,
|
||||
error,
|
||||
}: TextInputProps) => {
|
||||
const getStylesProps = () => {
|
||||
const baseStyles = 'px-3 py-2 '
|
||||
switch (style) {
|
||||
case 'main':
|
||||
return `p-4`
|
||||
case 'register':
|
||||
return `${baseStyles}`
|
||||
default:
|
||||
return baseStyles
|
||||
}
|
||||
}
|
||||
|
||||
const TextInput = () => {
|
||||
return (
|
||||
<div>TextInput</div>
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
{tooltip && <div className="tooltip">{tooltip}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={isPassword ? (isVisible ? 'text' : 'password') : type}
|
||||
id={name}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
className={`${getStylesProps()} w-full border bg-[#1B1E28] text-white ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
} rounded-xl focus:ring-1 focus:outline-none ${
|
||||
error ? 'focus:ring-red-400' : 'focus:ring-blue-400'
|
||||
}`}
|
||||
autoComplete={name}
|
||||
maxLength={maxLength}
|
||||
min={min}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-white hover:text-gray-700"
|
||||
>
|
||||
{isVisible ? <HiOutlineEye /> : <HiOutlineEyeOff />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="mt-1 text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextInput
|
||||
export default TextInput
|
||||
|
||||
34
frontend/components/ui/Tooltip.tsx
Normal file
34
frontend/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CiCircleInfo } from 'react-icons/ci'
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | React.ReactNode
|
||||
}
|
||||
|
||||
const Tooltip = ({ content }: TooltipProps) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center overflow-visible">
|
||||
<button
|
||||
type="button"
|
||||
className="text-orange hover:text-orange/80 focus:outline-none"
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
>
|
||||
<CiCircleInfo className="h-4 w-4" />
|
||||
</button>
|
||||
{showTooltip && (
|
||||
<div className="absolute bottom-full left-1/2 z-10 mb-3 w-max max-w-xs -translate-x-1/2 rounded-xl bg-white px-4 py-2 text-center text-sm text-gray-700 shadow-lg">
|
||||
{content}
|
||||
<div className="absolute -bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 transform bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
Reference in New Issue
Block a user