feature: add projects page (2 parts works)
This commit is contained in:
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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: "",
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface AppState {
|
||||
currentScreenName: string
|
||||
}
|
||||
@@ -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,
|
||||
@@ -0,0 +1,7 @@
|
||||
import { components } from "@shared/api/__generated__/openapi.types"
|
||||
|
||||
export type UserEntity = components["schemas"]["UserRead"]
|
||||
|
||||
export interface UserState {
|
||||
user: UserEntity | null
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ui/Avatar"
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export interface IAvatarProps {
|
||||
size?: "xxxlarge" | "xxlarge" | "xlarge" | "large" | "medium" | "small"
|
||||
url: string
|
||||
active?: boolean
|
||||
variant?: "circle" | "square"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
export interface ILoaderProps {
|
||||
fullscreen?: boolean;
|
||||
block?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Loader"
|
||||
+8
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user