feature: add projects page (2 parts works)

This commit is contained in:
Daniil
2026-01-29 00:57:22 +03:00
parent 3dfb9453ec
commit 2e4820ac91
65 changed files with 2223 additions and 45 deletions
+1
View File
@@ -21,6 +21,7 @@ export const verifyToken = async (token: string): Promise<boolean> => {
},
})
console.log("Verify token response:", resp)
if (resp.error) return false
return true
} catch (error) {
console.error("Verify token error:", error)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+2
View File
@@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from "react-redux"
import { store } from "@shared/store"
import { QueryClientProvider } from "./QueryClientProvider"
import { UserSync } from "./UserSync"
export const AppProviders = ({
children,
@@ -17,6 +18,7 @@ export const AppProviders = ({
return (
<ReduxProvider store={store}>
<QueryClientProvider>
<UserSync />
<Theme accentColor="violet" grayColor="slate" radius="medium">
{children}
</Theme>
+85
View File
@@ -0,0 +1,85 @@
"use client"
import type { JSX } from "react"
import { useEffect, useRef } from "react"
import Cookies from "js-cookie"
import { fetchClient } from "@shared/api"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { ACCESS_TOKEN_COOKIE } from "@shared/lib/constants"
import { setUser } from "@shared/store/user"
const TOKEN_POLL_INTERVAL = 2000
export const UserSync = (): JSX.Element | null => {
const dispatch = useAppDispatch()
const user = useAppSelector((state) => state.user.user)
const userRef = useRef(user)
const lastTokenRef = useRef<string | undefined>(undefined)
useEffect(() => {
userRef.current = user
}, [user])
useEffect(() => {
let isCancelled = false
const syncUserWithToken = async (): Promise<void> => {
if (typeof window === "undefined") return
const token = Cookies.get(ACCESS_TOKEN_COOKIE)
if (!token) {
lastTokenRef.current = undefined
if (userRef.current) {
dispatch(setUser(null))
}
return
}
// Skip refetch when token is unchanged and user is already cached
if (lastTokenRef.current === token && userRef.current) return
lastTokenRef.current = token
try {
const response = await fetchClient.GET("/api/users/me/")
if (isCancelled) return
if (response.data) {
dispatch(setUser(response.data))
return
}
} catch (error) {
console.error("Failed to fetch current user:", error)
}
if (!isCancelled) {
dispatch(setUser(null))
}
}
syncUserWithToken()
const intervalId = window.setInterval(
syncUserWithToken,
TOKEN_POLL_INTERVAL,
)
const handleFocus = (): void => {
syncUserWithToken()
}
window.addEventListener("focus", handleFocus)
return () => {
isCancelled = true
window.clearInterval(intervalId)
window.removeEventListener("focus", handleFocus)
}
}, [dispatch])
return null
}
+1 -1
View File
@@ -2,4 +2,4 @@ import type { AppDispatch } from "@shared/store"
import { useDispatch } from "react-redux"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppDispatch = (): AppDispatch => useDispatch<AppDispatch>()
+2 -1
View File
@@ -1,5 +1,6 @@
import type { RootState } from "@shared/store"
import type { TypedUseSelectorHook } from "react-redux"
import { useSelector } from "react-redux"
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
@@ -2,9 +2,7 @@ import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
export interface AppState {
currentScreenName: string
}
import { AppState } from "./types"
const initialState: AppState = {
currentScreenName: "",
+3
View File
@@ -0,0 +1,3 @@
export interface AppState {
currentScreenName: string
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { configureStore } from "@reduxjs/toolkit"
import { appStateReducer } from "./appStateStore"
import { userReducer } from "./userStore"
import { appStateReducer } from "./appState"
import { userReducer } from "./user"
export const store = configureStore({
reducer: {
@@ -1,13 +1,8 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import type { components } from "@shared/api/__generated__/openapi.types"
import { createSlice } from "@reduxjs/toolkit"
export type UserEntity = components["schemas"]["UserRead"]
export interface UserState {
user: UserEntity | null
}
import { UserEntity, UserState } from "./types"
const initialState: UserState = {
user: null,
+7
View File
@@ -0,0 +1,7 @@
import { components } from "@shared/api/__generated__/openapi.types"
export type UserEntity = components["schemas"]["UserRead"]
export interface UserState {
user: UserEntity | null
}
+9
View File
@@ -49,3 +49,12 @@
padding: 0;
list-style: none;
}
@mixin transparent-color($color, $transparency: 50%) {
color: color-mix(in srgb, $color $transparency, transparent);
}
@mixin transparent-bg($color, $transparency: 50%) {
background-color: color-mix(in srgb, $color $transparency, transparent);
}
+3
View File
@@ -33,3 +33,6 @@ $header-height: var(--header-height);
$text-primary: var(--text-primary);
$text-secondary: var(--text-secondary);
$bg-default: var(--bg-default);
$bg-surface: var(--bg-surface);
$bg-canvas: var(--bg-canvas);
+9 -4
View File
@@ -50,13 +50,18 @@ body {
--color-danger: #ef4444;
--color-warning: #facc15;
--color-primary: var(--purple-400);
--color-secondary: var(--green-800);
--color-primary: var(--green-800);
--color-secondary: var(--purple-400);
--color-white: #ffffff;
--color-black: #000000;
--text-primary: #0c1226;
--text-secondary: #5a5f73;
--bg-canvas: rgb(246, 245, 250);
--bg-default: rgb(255, 255, 255);
--bg-surface: rgba(245, 245, 245, 1);
--bg-default-invert: rgba(34, 35, 37, 1);
--text-primary: #111827;
--text-secondary: #6b7280;
--header-height: 56px;
}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Avatar"
+6
View File
@@ -0,0 +1,6 @@
export interface IAvatarProps {
size?: "xxxlarge" | "xxlarge" | "xlarge" | "large" | "medium" | "small"
url: string
active?: boolean
variant?: "circle" | "square"
}
+163
View File
@@ -0,0 +1,163 @@
.root {
position: relative;
display: flex;
flex-shrink: 0;
&.xxxlarge {
width: 128px;
height: 128px;
.icon {
bottom: 13x;
right: 13px;
}
}
&.xxlarge {
width: 96px;
height: 96px;
.icon {
bottom: 8x;
right: 8px;
}
}
&.xlarge {
width: 64px;
height: 64px;
.icon {
bottom: 5px;
right: 5px;
}
}
&.large {
width: 40px;
height: 40px;
.icon {
bottom: 2px;
right: 2px;
}
}
&.medium {
width: 32px;
height: 32px;
.icon {
bottom: 1px;
right: 1px;
}
}
&.small {
width: 24px;
height: 24px;
.icon {
display: none;
}
}
}
.img {
object-fit: cover;
&.xxxlarge {
width: 128px;
height: 128px;
&.square {
border-radius: 16px;
}
}
&.xxlarge {
width: 96px;
height: 96px;
&.square {
border-radius: 16px;
}
}
&.xlarge {
width: 64px;
height: 64px;
&.square {
border-radius: 16px;
}
}
&.small {
width: 24px;
height: 24px;
&.square {
border-radius: 4px;
}
}
&.medium {
width: 32px;
height: 32px;
&.square {
border-radius: 8px;
}
}
&.large {
width: 40px;
height: 40px;
&.square {
border-radius: 8px;
}
}
&.circle {
border-radius: 999px;
}
&.loading {
animation: shimmer 1.6s infinite linear;
background: linear-gradient(
to right,
variables.$color-secondary 8%,
variables.$color-white 18%,
variables.$color-secondary 33%
);
background-size: 800px 104px;
position: absolute;
width: 100%;
height: 100%;
}
}
.icon {
display: none;
position: absolute;
width: 8px;
height: 8px;
border-radius: 999px;
&.active {
display: inline;
background-color: #00ff00;
}
}
@keyframes shimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { JSX } from "react"
import { FunctionComponent, memo, useState } from "react"
import cs from "classnames"
import Image from "next/image"
import avatarPlaceholder from "@shared/assets/placeholder.png"
import { IAvatarProps } from "../model/Avatar"
import styles from "./Avatar.module.scss"
const avatarProperties = {
xxxlarge: {
width: 1024,
height: 1024,
},
xxlarge: {
width: 512,
height: 512,
},
xlarge: {
width: 512,
height: 512,
},
large: {
width: 256,
height: 256,
},
medium: {
width: 128,
height: 128,
},
small: {
width: 64,
height: 64,
},
}
export const Avatar: FunctionComponent<IAvatarProps> = memo(
({
size = "large",
variant = "circle",
url,
active = false,
}): JSX.Element => {
const [loaded, setLoaded] = useState(false)
const [imgURL, setImgURL] = useState(url || avatarPlaceholder.src)
return (
<div className={cs(styles.root, styles[size])}>
{url ? (
<>
<Image
priority
loading="eager"
className={cs(styles.img, styles[size], styles[variant], {
[styles.loading]: !loaded,
})}
onError={(e) => {
e.preventDefault()
setImgURL(avatarPlaceholder.src)
}}
onLoad={() => setLoaded(true)}
alt="avatar"
src={imgURL}
{...avatarProperties[size]}
/>
<div
className={cs(styles.icon, {
[styles.active]: active,
})}
/>
</>
) : (
<Image
priority
loading="eager"
className={cs(styles.img, styles[size], styles[variant], {
[styles.loading]: !loaded,
})}
alt="avatar"
src={imgURL}
{...avatarProperties[size]}
/>
)}
</div>
)
},
)
+2
View File
@@ -40,6 +40,7 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{children}
@@ -53,6 +54,7 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{children}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/CircularProgress"
@@ -0,0 +1,11 @@
export interface ICircularProgressProps {
percentage: number
className?: string
bgClassName?: string
valueClassName?: string
color?: string
size?: number
strokeWidth?: number
strokeLinecap?: "butt" | "round" | "square"
ariaLabel?: string
}
@@ -0,0 +1,3 @@
.root {
display: block;
}
@@ -0,0 +1,66 @@
import type { ICircularProgressProps } from "../model/CircularProgress.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import cs from "classnames"
import styles from "./CircularProgress.module.scss"
const clampPercentage = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(100, Math.max(0, value))
}
export const CircularProgress: FunctionComponent<ICircularProgressProps> = ({
percentage,
className,
bgClassName,
valueClassName,
color,
size = 48,
strokeWidth = 4,
strokeLinecap = "round",
ariaLabel,
}): JSX.Element => {
const safePercentage = clampPercentage(percentage)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (safePercentage / 100) * circumference
const ariaProps = ariaLabel
? { role: "img", "aria-label": ariaLabel }
: { "aria-hidden": true }
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cs(styles.root, className)}
data-testid="CircularProgress"
{...ariaProps}
>
<circle
className={bgClassName}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
className={valueClassName}
stroke={color}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={size / 2}
cy={size / 2}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap={strokeLinecap}
/>
</svg>
)
}
+29
View File
@@ -0,0 +1,29 @@
export {
Dropdown,
DropdownCheckboxItem,
DropdownContent,
DropdownItem,
DropdownLabel,
DropdownRadioGroup,
DropdownRadioItem,
DropdownSeparator,
DropdownSub,
DropdownSubContent,
DropdownSubTrigger,
DropdownTrigger,
} from "./ui/Dropdown"
export type {
IDropdownCheckboxItemProps,
IDropdownContentProps,
IDropdownItemProps,
IDropdownLabelProps,
IDropdownProps,
IDropdownRadioGroupProps,
IDropdownRadioItemProps,
IDropdownSeparatorProps,
IDropdownSubContentProps,
IDropdownSubProps,
IDropdownSubTriggerProps,
IDropdownTriggerProps,
} from "./model/Dropdown.d"
+82
View File
@@ -0,0 +1,82 @@
import type * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import type { ComponentProps, ReactNode } from "react"
export interface IDropdownProps extends ComponentProps<
typeof DropdownMenu.Root
> {
children?: ReactNode
}
export interface IDropdownTriggerProps extends ComponentProps<
typeof DropdownMenu.Trigger
> {
children?: ReactNode
asChild?: boolean
}
export interface IDropdownContentProps extends ComponentProps<
typeof DropdownMenu.Content
> {
children?: ReactNode
sideOffset?: number
align?: "start" | "center" | "end"
}
export interface IDropdownItemProps extends ComponentProps<
typeof DropdownMenu.Item
> {
children?: ReactNode
disabled?: boolean
}
export interface IDropdownCheckboxItemProps extends ComponentProps<
typeof DropdownMenu.CheckboxItem
> {
children?: ReactNode
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}
export interface IDropdownRadioGroupProps extends ComponentProps<
typeof DropdownMenu.RadioGroup
> {
children?: ReactNode
value?: string
onValueChange?: (value: string) => void
}
export interface IDropdownRadioItemProps extends ComponentProps<
typeof DropdownMenu.RadioItem
> {
children?: ReactNode
value: string
}
export interface IDropdownLabelProps extends ComponentProps<
typeof DropdownMenu.Label
> {
children?: ReactNode
}
export interface IDropdownSeparatorProps extends ComponentProps<
typeof DropdownMenu.Separator
> {}
export interface IDropdownSubProps extends ComponentProps<
typeof DropdownMenu.Sub
> {
children?: ReactNode
}
export interface IDropdownSubTriggerProps extends ComponentProps<
typeof DropdownMenu.SubTrigger
> {
children?: ReactNode
}
export interface IDropdownSubContentProps extends ComponentProps<
typeof DropdownMenu.SubContent
> {
children?: ReactNode
sideOffset?: number
}
@@ -0,0 +1,94 @@
@use "@shared/styles/variables" as *;
.trigger {
all: unset;
cursor: pointer;
&:focus-visible {
outline: 2px solid $color-primary;
outline-offset: 2px;
}
}
.content,
.subContent {
z-index: 100;
min-width: 180px;
padding: 8px;
background-color: $color-white;
border: 1px solid $color-primary;
border-radius: 8px;
box-shadow:
0 10px 38px -10px rgb(22 23 24 / 35%),
0 10px 20px -15px rgb(22 23 24 / 20%);
animation: fadeIn 0.15s ease-out;
}
.item,
.checkboxItem,
.radioItem,
.subTrigger {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
font-size: 14px;
color: $text-primary;
cursor: pointer;
border-radius: 4px;
outline: none;
transition: background-color 0.15s ease;
color: variables.$text-primary;
&[data-highlighted] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
}
&[data-disabled] {
color: $text-secondary;
pointer-events: none;
opacity: 0.5;
}
}
.subTrigger {
justify-content: space-between;
&[data-state="open"] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
}
}
.itemIndicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
color: $color-secondary;
}
.label {
padding: 8px 12px 4px;
font-size: 12px;
font-weight: 500;
color: $text-secondary;
}
.separator {
height: 1px;
margin: 8px 0;
background-color: color-mix(in srgb, variables.$color-primary 20%, transparent);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
+198
View File
@@ -0,0 +1,198 @@
"use client"
import type {
IDropdownCheckboxItemProps,
IDropdownContentProps,
IDropdownItemProps,
IDropdownLabelProps,
IDropdownProps,
IDropdownRadioGroupProps,
IDropdownRadioItemProps,
IDropdownSeparatorProps,
IDropdownSubContentProps,
IDropdownSubProps,
IDropdownSubTriggerProps,
IDropdownTriggerProps,
} from "../model/Dropdown.d"
import type { JSX } from "react"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { forwardRef } from "react"
import cs from "classnames"
import styles from "./Dropdown.module.scss"
export const Dropdown = ({
children,
...props
}: IDropdownProps): JSX.Element => (
<DropdownMenu.Root {...props}>{children}</DropdownMenu.Root>
)
export const DropdownTrigger = forwardRef<
HTMLButtonElement,
IDropdownTriggerProps
>(
({ children, className, asChild = false, ...props }, ref): JSX.Element => (
<DropdownMenu.Trigger
ref={ref}
className={cs(styles.trigger, className)}
asChild={asChild}
{...props}
>
{children}
</DropdownMenu.Trigger>
),
)
DropdownTrigger.displayName = "DropdownTrigger"
export const DropdownContent = forwardRef<
HTMLDivElement,
IDropdownContentProps
>(
(
{ children, className, sideOffset = 4, align = "end", ...props },
ref,
): JSX.Element => (
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={ref}
className={cs(styles.content, className)}
sideOffset={sideOffset}
align={align}
{...props}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
),
)
DropdownContent.displayName = "DropdownContent"
export const DropdownItem = forwardRef<HTMLDivElement, IDropdownItemProps>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.Item
ref={ref}
className={cs(styles.item, className)}
{...props}
>
{children}
</DropdownMenu.Item>
),
)
DropdownItem.displayName = "DropdownItem"
export const DropdownCheckboxItem = forwardRef<
HTMLDivElement,
IDropdownCheckboxItemProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.CheckboxItem
ref={ref}
className={cs(styles.checkboxItem, className)}
{...props}
>
<DropdownMenu.ItemIndicator className={styles.itemIndicator}>
</DropdownMenu.ItemIndicator>
{children}
</DropdownMenu.CheckboxItem>
),
)
DropdownCheckboxItem.displayName = "DropdownCheckboxItem"
export const DropdownRadioGroup = ({
children,
...props
}: IDropdownRadioGroupProps): JSX.Element => (
<DropdownMenu.RadioGroup {...props}>{children}</DropdownMenu.RadioGroup>
)
export const DropdownRadioItem = forwardRef<
HTMLDivElement,
IDropdownRadioItemProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.RadioItem
ref={ref}
className={cs(styles.radioItem, className)}
{...props}
>
<DropdownMenu.ItemIndicator className={styles.itemIndicator}>
</DropdownMenu.ItemIndicator>
{children}
</DropdownMenu.RadioItem>
),
)
DropdownRadioItem.displayName = "DropdownRadioItem"
export const DropdownLabel = forwardRef<HTMLDivElement, IDropdownLabelProps>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.Label
ref={ref}
className={cs(styles.label, className)}
{...props}
>
{children}
</DropdownMenu.Label>
),
)
DropdownLabel.displayName = "DropdownLabel"
export const DropdownSeparator = forwardRef<
HTMLDivElement,
IDropdownSeparatorProps
>(
({ className, ...props }, ref): JSX.Element => (
<DropdownMenu.Separator
ref={ref}
className={cs(styles.separator, className)}
{...props}
/>
),
)
DropdownSeparator.displayName = "DropdownSeparator"
export const DropdownSub = ({
children,
...props
}: IDropdownSubProps): JSX.Element => (
<DropdownMenu.Sub {...props}>{children}</DropdownMenu.Sub>
)
export const DropdownSubTrigger = forwardRef<
HTMLDivElement,
IDropdownSubTriggerProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.SubTrigger
ref={ref}
className={cs(styles.subTrigger, className)}
{...props}
>
{children}
</DropdownMenu.SubTrigger>
),
)
DropdownSubTrigger.displayName = "DropdownSubTrigger"
export const DropdownSubContent = forwardRef<
HTMLDivElement,
IDropdownSubContentProps
>(
({ children, className, sideOffset = 2, ...props }, ref): JSX.Element => (
<DropdownMenu.Portal>
<DropdownMenu.SubContent
ref={ref}
className={cs(styles.subContent, className)}
sideOffset={sideOffset}
{...props}
>
{children}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
),
)
DropdownSubContent.displayName = "DropdownSubContent"
+5
View File
@@ -0,0 +1,5 @@
export interface ILoaderProps {
fullscreen?: boolean;
block?: boolean;
description?: string;
}
+127
View File
@@ -0,0 +1,127 @@
.root {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
width: 100%;
height: 100%;
pointer-events: none;
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
background-color: variables.$bg-default;
}
&.block {
width: 100%;
height: 100%;
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
width: 100%;
height: 100%;
}
}
.container {
position: relative;
width: 72px;
height: 72px;
}
.loader {
position: absolute;
top: 0;
left: 0;
border: 2px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 64px;
height: 64px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 1s linear infinite;
&.blue {
border: 4px solid color-mix(in srgb, variables.$color-secondary 50%, transparent);
border-left-color: transparent;
border-top-color: transparent;
animation: spin 2s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.description {
@include typography.font-display;
color: variables.$text-primary;
text-align: center;
&.in {
animation: fadeIn 150ms ease-in-out;
}
&.out {
animation: fadeOut 150ms ease-in-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(5px);
}
}
.minLoader {
border: 4px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 40px;
height: 40px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 0.5s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { JSX } from "react"
import { FunctionComponent, memo } from "react"
import cs from "classnames"
import { ILoaderProps } from "./Loader.d"
import styles from "./Loader.module.scss"
export const StaticLoader: FunctionComponent<ILoaderProps> = memo(
({ fullscreen = false, block = false, description }): JSX.Element => {
if (!fullscreen && !block) {
return (
<div className={styles.root}>
<div className={styles.minLoader} />
</div>
)
}
return (
<div
className={cs(styles.root, {
[styles.fullscreen]: fullscreen,
[styles.block]: block,
})}
>
<div className={styles.container}>
<div className={styles.loader} />
<div className={cs(styles.loader, styles.blue)} />
</div>
<h4 className={styles.description}>{description || "Загрузка"}</h4>
</div>
)
},
)
+1
View File
@@ -0,0 +1 @@
export * from "./Loader"
+8 -2
View File
@@ -1,10 +1,16 @@
import type { TextField } from "@radix-ui/themes"
import type { ComponentProps, ReactNode } from "react"
export interface ITextFieldProps
extends Omit<ComponentProps<typeof TextField.Root>, "color"> {
type TextFieldRootProps = Omit<
ComponentProps<typeof TextField.Root>,
"children"
>
export interface ITextFieldProps extends TextFieldRootProps {
id: string
label?: ReactNode
undertitle?: ReactNode
error?: boolean
startIcon?: ReactNode
endIcon?: ReactNode
}
+36 -7
View File
@@ -3,11 +3,29 @@
import type { ITextFieldProps } from "../model/TextField.d"
import type { JSX } from "react"
import { Text, TextField as RadixTextField } from "@radix-ui/themes"
import { forwardRef } from "react"
import { TextField as RadixTextField, Text } from "@radix-ui/themes"
export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
({ id, label, undertitle, error, size = "2", ...props }, ref): JSX.Element => (
(
{
id,
label,
undertitle,
error,
size = "2",
variant,
radius,
startIcon,
endIcon,
className,
style,
color,
...rootProps
},
ref,
): JSX.Element => (
<label htmlFor={id}>
{label && (
<Text as="div" size="2" mb="1" weight="medium">
@@ -15,14 +33,25 @@ export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
</Text>
)}
<RadixTextField.Root
id={id}
ref={ref}
size={size}
color={error ? "red" : undefined}
variant={variant}
radius={radius}
color={error ? "red" : color}
className={className}
style={style}
ref={ref}
aria-describedby={undertitle ? `${id}-undertitle` : undefined}
aria-invalid={error}
{...props}
/>
id={id}
{...rootProps}
>
{startIcon && (
<RadixTextField.Slot side="left">{startIcon}</RadixTextField.Slot>
)}
{endIcon && (
<RadixTextField.Slot side="right">{endIcon}</RadixTextField.Slot>
)}
</RadixTextField.Root>
{undertitle && (
<Text as="p" size="1" color="gray" mt="1" id={`${id}-undertitle`}>
{undertitle}
+1
View File
@@ -3,6 +3,7 @@ export * from "./Badge"
export * from "./Button"
export * from "./Card"
export * from "./Checkbox"
export * from "./CircularProgress"
export * from "./Form"
export * from "./TextField"
export * from "./Modal"