feature: add projects page (2 parts works)
This commit is contained in:
@@ -49,17 +49,22 @@ if (layer === "shared") {
|
|||||||
|
|
||||||
const fsdCommand = `fsd ${layer} ${component} ${otherArgs} --segments ${segments} -r src`
|
const fsdCommand = `fsd ${layer} ${component} ${otherArgs} --segments ${segments} -r src`
|
||||||
|
|
||||||
try {
|
if (layer !== "shared") {
|
||||||
|
try {
|
||||||
runShell(fsdCommand)
|
runShell(fsdCommand)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Ошибка выполнения команды: ${(error as Error).message}`)
|
console.error(`Ошибка выполнения команды: ${(error as Error).message}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await mkdir(componentPath, { recursive: true })
|
||||||
|
console.log("Пропущен fsd для shared: создаем компонент вручную")
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Удаление индекс файла: ${componentPath}/index.ts`)
|
console.log(`Удаление индекс файла: ${componentPath}/index.ts`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await rm(`${componentPath}/index.ts`)
|
await rm(`${componentPath}/index.ts`, { force: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Ошибка выполнения команды: ${(error as Error).message}`)
|
console.error(`Ошибка выполнения команды: ${(error as Error).message}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { JSX } from "react"
|
||||||
|
|
||||||
|
import { ProjectsPage } from "@pages/ProjectsPage"
|
||||||
|
|
||||||
|
export default function Projects(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ProjectsPage />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ui/NavigationDrawer"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ComponentType } from "react"
|
||||||
|
|
||||||
|
export interface NavigationDrawerButton {
|
||||||
|
label: string
|
||||||
|
icon?: ComponentType<{ className?: string }>
|
||||||
|
path?: string
|
||||||
|
action?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INavigationDrawerProps {
|
||||||
|
buttons: NavigationDrawerButton[]
|
||||||
|
className?: string
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
position?: "left" | "right" | "top" | "bottom"
|
||||||
|
size?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
.drawer {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 220px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #0c1226;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link,
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #0c1226;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover,
|
||||||
|
.button:hover,
|
||||||
|
.link:focus-visible,
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background-color: #f4f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: #5a6473;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
import Drawer from "react-modern-drawer"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import { INavigationDrawerProps } from "../model/NavigationDrawer.d"
|
||||||
|
import styles from "./NavigationDrawer.module.scss"
|
||||||
|
|
||||||
|
import "react-modern-drawer/dist/index.css"
|
||||||
|
|
||||||
|
export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
|
||||||
|
buttons,
|
||||||
|
className,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
position = "left",
|
||||||
|
size = 280,
|
||||||
|
title,
|
||||||
|
}): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
direction={position}
|
||||||
|
size={size}
|
||||||
|
className={cs(styles.drawer, className)}
|
||||||
|
aria-label="Navigation drawer"
|
||||||
|
duration={200}
|
||||||
|
>
|
||||||
|
<nav className={styles.root} data-testid="NavigationDrawer">
|
||||||
|
{title ? <div className={styles.header}>{title}</div> : null}
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{buttons.map(({ label, icon: Icon, path, action }, index) => {
|
||||||
|
const key = `${label}-${path ?? index}`
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{Icon ? <Icon className={styles.icon} /> : null}
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (): void => {
|
||||||
|
action?.()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={styles.item} key={key}>
|
||||||
|
{path ? (
|
||||||
|
<Link
|
||||||
|
href={path}
|
||||||
|
className={styles.link}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ui/ProjectCard"
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { components } from "@shared/api/__generated__/openapi.types"
|
||||||
|
|
||||||
|
export interface IProjectCardProps {
|
||||||
|
project: components["schemas"]["ProjectRead"]
|
||||||
|
className?: string
|
||||||
|
/**
|
||||||
|
* Progress percentage (0-100)
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
progress?: number
|
||||||
|
/**
|
||||||
|
* Current action name (e.g. "Rendering", "Uploading")
|
||||||
|
*/
|
||||||
|
currentAction?: string
|
||||||
|
/**
|
||||||
|
* Hero image URL
|
||||||
|
*/
|
||||||
|
imageUrl?: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
|
||||||
|
.root {
|
||||||
|
@include mixins.flex-column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
background-color: variables.$purple-50;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
@include mixins.flex-center;
|
||||||
|
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: variables.$purple-300;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressCircle {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
circle {
|
||||||
|
transition: stroke-dashoffset 0.35s;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressBg {
|
||||||
|
stroke: variables.$purple-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressValue {
|
||||||
|
stroke: variables.$purple-400;
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
stroke: variables.$color-success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.percentage {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
font-weight: 600;
|
||||||
|
color: variables.$color-white;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.18) 100%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionName {
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
font-size: 10px;
|
||||||
|
color: variables.$color-white;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 80px;
|
||||||
|
@include mixins.text-ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
@include mixins.flex-column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include typography.font-body-16(600);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
@include mixins.text-ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
@include typography.font-body-s;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.statusGenerated {
|
||||||
|
color: variables.$color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.statusProcessing, &.statusRendering, &.statusUploading {
|
||||||
|
color: variables.$purple-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.statusDraft {
|
||||||
|
color: variables.$text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.statusFailed {
|
||||||
|
color: variables.$color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuTrigger {
|
||||||
|
margin-left: auto;
|
||||||
|
background-color: variables.$bg-surface;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
@include mixins.flex-center;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[data-state="open"] {
|
||||||
|
background-color: variables.$bg-default;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include mixins.flex-center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: variables.$bg-canvas;
|
||||||
|
@include typography.font-caption-m;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { IProjectCardProps } from "../model/ProjectCard.d"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import cs from "classnames"
|
||||||
|
import moment from "moment"
|
||||||
|
|
||||||
|
import { Card } from "@shared/ui/Card"
|
||||||
|
import { CircularProgress } from "@shared/ui/CircularProgress"
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownContent,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownTrigger,
|
||||||
|
} from "@shared/ui/Dropdown"
|
||||||
|
|
||||||
|
import styles from "./ProjectCard.module.scss"
|
||||||
|
|
||||||
|
export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
|
||||||
|
project,
|
||||||
|
className,
|
||||||
|
progress = 0,
|
||||||
|
currentAction,
|
||||||
|
imageUrl,
|
||||||
|
onClick,
|
||||||
|
}): JSX.Element => {
|
||||||
|
const { name, updated_at, status } = project
|
||||||
|
|
||||||
|
const temporaryStatuses = new Set(["PROCESSING", "RENDERING", "UPLOADING"])
|
||||||
|
const isCompleted = status === "DONE"
|
||||||
|
const isProcessing = temporaryStatuses.has(status)
|
||||||
|
const isDraft = status === "DRAFT"
|
||||||
|
const isFailed = status === "FAILED"
|
||||||
|
|
||||||
|
const shouldShowProgress = isProcessing
|
||||||
|
|
||||||
|
// Helper to determine status color/class
|
||||||
|
const getStatusClass = () => {
|
||||||
|
if (isCompleted) return styles.statusGenerated
|
||||||
|
if (isProcessing) return styles.statusProcessing
|
||||||
|
if (isDraft) return styles.statusDraft
|
||||||
|
if (isFailed) return styles.statusFailed
|
||||||
|
return styles.statusDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = () => {
|
||||||
|
if (isCompleted) return "Завершено"
|
||||||
|
if (isProcessing) return "В процессе" // Or more specific state
|
||||||
|
if (isDraft) return "Черновик"
|
||||||
|
if (isFailed) return "Ошибка"
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayAction = currentAction || (isProcessing ? "В процессе" : "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cs(styles.root, className)} onClick={onClick}>
|
||||||
|
<div className={styles.hero}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={name} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<ImageIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.statusBadge}>
|
||||||
|
<div className={cs(styles.status, getStatusClass())}>
|
||||||
|
<span className={styles.statusDot} />
|
||||||
|
{getStatusLabel()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shouldShowProgress && (
|
||||||
|
<div className={styles.progressOverlay}>
|
||||||
|
<div className={styles.progressCircle}>
|
||||||
|
<CircularProgress
|
||||||
|
percentage={Math.min(100, Math.max(0, progress))}
|
||||||
|
color="var(--purple-500)"
|
||||||
|
bgClassName={styles.progressBg}
|
||||||
|
valueClassName={styles.progressValue}
|
||||||
|
/>
|
||||||
|
<span className={styles.percentage}>{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
{displayAction && (
|
||||||
|
<span className={styles.actionName} title={displayAction}>
|
||||||
|
{displayAction}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.infoHeader}>
|
||||||
|
<h3 className={styles.title} title={name}>
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={styles.menuTrigger}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger asChild>
|
||||||
|
<button type="button" aria-label="Project actions">
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownContent align="end">
|
||||||
|
<DropdownItem
|
||||||
|
onSelect={() => console.log("Edit", project.id)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
onSelect={() => console.log("Rename", project.id)}
|
||||||
|
>
|
||||||
|
Переименовать
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
className="text-red-500"
|
||||||
|
onSelect={() => console.log("Delete", project.id)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.date}>
|
||||||
|
Создано {moment(updated_at).fromNow()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ui/UserDropdown"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { UserEntity } from "@shared/store/user/types"
|
||||||
|
|
||||||
|
export interface IUserDropdownProps {
|
||||||
|
user: UserEntity | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const userDropdownValues = [
|
||||||
|
{
|
||||||
|
label: "Профиль",
|
||||||
|
acton: "profile",
|
||||||
|
path: "/profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Настройки",
|
||||||
|
acton: "settings",
|
||||||
|
path: "/settings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Выйти",
|
||||||
|
acton: "logout",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.root {
|
||||||
|
display: flex
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
@include typography.font-body-16(500);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px ;
|
||||||
|
border-radius: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import { Avatar } from "@shared/ui/Avatar"
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownContent,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownSeparator,
|
||||||
|
DropdownTrigger,
|
||||||
|
} from "@shared/ui/Dropdown"
|
||||||
|
|
||||||
|
import { userDropdownValues } from "../model/constants"
|
||||||
|
import { IUserDropdownProps } from "../model/UserDropdown.d"
|
||||||
|
import styles from "./UserDropdown.module.scss"
|
||||||
|
|
||||||
|
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
|
||||||
|
user,
|
||||||
|
}): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className={styles.root} data-testid="UserDropdown">
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger asChild>
|
||||||
|
<div className={styles.trigger}>
|
||||||
|
<Avatar size="small" url={user?.avatar || ""} />
|
||||||
|
<span className={styles.username}>{user?.username}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownContent>
|
||||||
|
{userDropdownValues.map((item) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={item.acton}
|
||||||
|
className={styles.item}
|
||||||
|
onSelect={() => console.log(`${item.acton} selected`)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { components } from "@shared/api/__generated__/openapi.types"
|
||||||
|
|
||||||
|
import api from "@shared/api"
|
||||||
|
|
||||||
|
export type ProjectCreateBody = components["schemas"]["ProjectCreate"]
|
||||||
|
export type ProjectRead = components["schemas"]["ProjectRead"]
|
||||||
|
|
||||||
|
interface IUseCreateProjectParams {
|
||||||
|
onSuccess?: (project: ProjectRead) => void
|
||||||
|
onError?: (error: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateProject = ({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: IUseCreateProjectParams = {}) => {
|
||||||
|
return api.useMutation("post", "/api/projects/", {
|
||||||
|
onSuccess: (project) => {
|
||||||
|
onSuccess?.(project)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
onError?.(error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateProjectModal } from "./ui/CreateProjectModal"
|
||||||
|
|
||||||
|
export type { ICreateProjectModalProps } from "./model/CreateProjectModal.d"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Dialog } from "@radix-ui/themes"
|
||||||
|
import type { ComponentProps } from "react"
|
||||||
|
|
||||||
|
export interface ICreateProjectModalProps extends Pick<
|
||||||
|
ComponentProps<typeof Dialog.Root>,
|
||||||
|
"open" | "onOpenChange"
|
||||||
|
> {
|
||||||
|
onCreated?: () => void | Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.root {
|
||||||
|
min-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectField {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ProjectCreateBody } from "../api/useCreateProject"
|
||||||
|
import type { ICreateProjectModalProps } from "../model/CreateProjectModal.d"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { FunctionComponent, useEffect } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
|
||||||
|
|
||||||
|
import { useCreateProject } from "../api/useCreateProject"
|
||||||
|
import styles from "./CreateProjectModal.module.scss"
|
||||||
|
|
||||||
|
type ProjectStatus = ProjectCreateBody["status"]
|
||||||
|
|
||||||
|
interface ICreateProjectFormData {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
language: string
|
||||||
|
folder?: string
|
||||||
|
status: ProjectStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
|
||||||
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
{ value: "PROCESSING", label: "Processing" },
|
||||||
|
{ value: "DONE", label: "Done" },
|
||||||
|
{ value: "FAILED", label: "Failed" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "ru", label: "Russian" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CreateProjectModal: FunctionComponent<
|
||||||
|
ICreateProjectModalProps
|
||||||
|
> = ({ open, onOpenChange, onCreated }): JSX.Element => {
|
||||||
|
const { control, register, handleSubmit, reset, formState } =
|
||||||
|
useForm<ICreateProjectFormData>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
folder: "",
|
||||||
|
language: "auto",
|
||||||
|
status: "DRAFT",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mutate, isPending } = useCreateProject({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await onCreated?.()
|
||||||
|
onOpenChange?.(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Create project failed:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) reset()
|
||||||
|
}, [open, reset])
|
||||||
|
|
||||||
|
const onSubmit = (data: ICreateProjectFormData): void => {
|
||||||
|
const name = data.name.trim()
|
||||||
|
const description = data.description?.trim()
|
||||||
|
const folder = data.folder?.trim()
|
||||||
|
|
||||||
|
mutate({
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
description: description?.length ? description : undefined,
|
||||||
|
folder: folder?.length ? folder : undefined,
|
||||||
|
language: data.language,
|
||||||
|
status: data.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Создать проект"
|
||||||
|
description="Заполните основные поля проекта"
|
||||||
|
>
|
||||||
|
<div className={styles.root} data-testid="CreateProjectModal">
|
||||||
|
<Form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<TextField
|
||||||
|
id="project_name"
|
||||||
|
label="Название"
|
||||||
|
placeholder="Например: Мой первый проект"
|
||||||
|
error={Boolean(formState.errors.name)}
|
||||||
|
undertitle={formState.errors.name?.message}
|
||||||
|
{...register("name", {
|
||||||
|
required: "Введите название проекта",
|
||||||
|
validate: (v) =>
|
||||||
|
v.trim().length > 0 || "Введите название проекта",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
id="project_description"
|
||||||
|
label="Описание"
|
||||||
|
placeholder="Коротко опишите проект (необязательно)"
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
id="project_folder"
|
||||||
|
label="Папка"
|
||||||
|
placeholder="Например: /projects/my-project (необязательно)"
|
||||||
|
{...register("folder")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.selectField}>
|
||||||
|
<div className={styles.selectLabel}>Язык</div>
|
||||||
|
<Controller
|
||||||
|
name="language"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выберите язык"
|
||||||
|
>
|
||||||
|
{LANGUAGE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.selectField}>
|
||||||
|
<div className={styles.selectLabel}>Статус</div>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
placeholder="Выберите статус"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => onOpenChange?.(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" disabled={isPending}>
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { FunctionComponent } from "react"
|
|||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
import api from "@shared/api"
|
import api from "@shared/api"
|
||||||
import { useCookie } from "@shared/hooks/useCookie"
|
import { useCookie } from "@shared/hooks/useCookie"
|
||||||
@@ -26,6 +27,7 @@ interface ILoginFormData {
|
|||||||
export const LoginPage: FunctionComponent<
|
export const LoginPage: FunctionComponent<
|
||||||
ILoginPageProps
|
ILoginPageProps
|
||||||
> = (): JSX.Element => {
|
> = (): JSX.Element => {
|
||||||
|
const router = useRouter()
|
||||||
const [, setAccessTokenCookie] = useCookie(ACCESS_TOKEN_COOKIE)
|
const [, setAccessTokenCookie] = useCookie(ACCESS_TOKEN_COOKIE)
|
||||||
const [, setRefreshTokenCookie] = useCookie(REFRESH_TOKEN_COOKIE)
|
const [, setRefreshTokenCookie] = useCookie(REFRESH_TOKEN_COOKIE)
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ export const LoginPage: FunctionComponent<
|
|||||||
onSuccess: ({ access, refresh, user }) => {
|
onSuccess: ({ access, refresh, user }) => {
|
||||||
setAccessTokenCookie(access)
|
setAccessTokenCookie(access)
|
||||||
setRefreshTokenCookie(refresh)
|
setRefreshTokenCookie(refresh)
|
||||||
console.log("Login successful:", user)
|
router.push("/")
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Login failed:", error)
|
console.error("Login failed:", error)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ui/ProjectsPage"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IProjectsPageProps {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
.root {
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include typography.font-display;
|
||||||
|
color: variables.$text-primary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
@include typography.font-body-16(600);
|
||||||
|
color: variables.$text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projectList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { PlusIcon } from "lucide-react"
|
||||||
|
import { FunctionComponent, useState } from "react"
|
||||||
|
|
||||||
|
import { ProjectCard } from "@entities/ProjectCard"
|
||||||
|
import { CreateProjectModal } from "@features/CreateProjectModal"
|
||||||
|
import api from "@shared/api"
|
||||||
|
import { Button } from "@shared/ui"
|
||||||
|
import { StaticLoader } from "@shared/ui/Loader"
|
||||||
|
import { ProjectsHeader } from "@widgets/Projects/ProjectsHeader/ui/ProjectsHeader"
|
||||||
|
|
||||||
|
import { IProjectsPageProps } from "../model/ProjectsPage.d"
|
||||||
|
import styles from "./ProjectsPage.module.scss"
|
||||||
|
|
||||||
|
export const ProjectsPage: FunctionComponent<
|
||||||
|
IProjectsPageProps
|
||||||
|
> = (): JSX.Element => {
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: projects,
|
||||||
|
isLoading: projectsLoading,
|
||||||
|
refetch: refetchProjects,
|
||||||
|
} = api.useQuery("get", "/api/projects/")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root} data-testid="ProjectsPage">
|
||||||
|
{projectsLoading && <StaticLoader fullscreen />}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.titles}>
|
||||||
|
<h1 className={styles.title}>Мои проекты</h1>
|
||||||
|
<h4 className={styles.subtitle}>
|
||||||
|
Управляйте своими последними проектами
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon /> Создать проект
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateProjectModal
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onOpenChange={setIsCreateModalOpen}
|
||||||
|
onCreated={async () => {
|
||||||
|
await refetchProjects()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectsHeader />
|
||||||
|
|
||||||
|
<div className={styles.projectList}>
|
||||||
|
{projects?.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
// Mock random progress for demo since API doesn't provide it yet
|
||||||
|
progress={project.status === "PROCESSING" ? 45 : 0}
|
||||||
|
currentAction={
|
||||||
|
project.status === "PROCESSING" ? "Rendering" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export const verifyToken = async (token: string): Promise<boolean> => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log("Verify token response:", resp)
|
console.log("Verify token response:", resp)
|
||||||
|
if (resp.error) return false
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Verify token error:", 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 { store } from "@shared/store"
|
||||||
|
|
||||||
import { QueryClientProvider } from "./QueryClientProvider"
|
import { QueryClientProvider } from "./QueryClientProvider"
|
||||||
|
import { UserSync } from "./UserSync"
|
||||||
|
|
||||||
export const AppProviders = ({
|
export const AppProviders = ({
|
||||||
children,
|
children,
|
||||||
@@ -17,6 +18,7 @@ export const AppProviders = ({
|
|||||||
return (
|
return (
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<QueryClientProvider>
|
<QueryClientProvider>
|
||||||
|
<UserSync />
|
||||||
<Theme accentColor="violet" grayColor="slate" radius="medium">
|
<Theme accentColor="violet" grayColor="slate" radius="medium">
|
||||||
{children}
|
{children}
|
||||||
</Theme>
|
</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"
|
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 { RootState } from "@shared/store"
|
||||||
|
import type { TypedUseSelectorHook } from "react-redux"
|
||||||
|
|
||||||
import { useSelector } 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"
|
import { createSlice } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
export interface AppState {
|
import { AppState } from "./types"
|
||||||
currentScreenName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: AppState = {
|
const initialState: AppState = {
|
||||||
currentScreenName: "",
|
currentScreenName: "",
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface AppState {
|
||||||
|
currentScreenName: string
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit"
|
import { configureStore } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
import { appStateReducer } from "./appStateStore"
|
import { appStateReducer } from "./appState"
|
||||||
import { userReducer } from "./userStore"
|
import { userReducer } from "./user"
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import type { PayloadAction } from "@reduxjs/toolkit"
|
import type { PayloadAction } from "@reduxjs/toolkit"
|
||||||
import type { components } from "@shared/api/__generated__/openapi.types"
|
|
||||||
|
|
||||||
import { createSlice } from "@reduxjs/toolkit"
|
import { createSlice } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
export type UserEntity = components["schemas"]["UserRead"]
|
import { UserEntity, UserState } from "./types"
|
||||||
|
|
||||||
export interface UserState {
|
|
||||||
user: UserEntity | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: UserState = {
|
const initialState: UserState = {
|
||||||
user: null,
|
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;
|
padding: 0;
|
||||||
list-style: none;
|
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-primary: var(--text-primary);
|
||||||
$text-secondary: var(--text-secondary);
|
$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-danger: #ef4444;
|
||||||
--color-warning: #facc15;
|
--color-warning: #facc15;
|
||||||
|
|
||||||
--color-primary: var(--purple-400);
|
--color-primary: var(--green-800);
|
||||||
--color-secondary: var(--green-800);
|
--color-secondary: var(--purple-400);
|
||||||
--color-white: #ffffff;
|
--color-white: #ffffff;
|
||||||
--color-black: #000000;
|
--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;
|
--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}
|
size={radixSize}
|
||||||
variant={visual.variant}
|
variant={visual.variant}
|
||||||
color={visual.color}
|
color={visual.color}
|
||||||
|
style={{ cursor: "pointer", ...props.style }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -53,6 +54,7 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
|
|||||||
size={radixSize}
|
size={radixSize}
|
||||||
variant={visual.variant}
|
variant={visual.variant}
|
||||||
color={visual.color}
|
color={visual.color}
|
||||||
|
style={{ cursor: "pointer", ...props.style }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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 { TextField } from "@radix-ui/themes"
|
||||||
import type { ComponentProps, ReactNode } from "react"
|
import type { ComponentProps, ReactNode } from "react"
|
||||||
|
|
||||||
export interface ITextFieldProps
|
type TextFieldRootProps = Omit<
|
||||||
extends Omit<ComponentProps<typeof TextField.Root>, "color"> {
|
ComponentProps<typeof TextField.Root>,
|
||||||
|
"children"
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface ITextFieldProps extends TextFieldRootProps {
|
||||||
id: string
|
id: string
|
||||||
label?: ReactNode
|
label?: ReactNode
|
||||||
undertitle?: ReactNode
|
undertitle?: ReactNode
|
||||||
error?: boolean
|
error?: boolean
|
||||||
|
startIcon?: ReactNode
|
||||||
|
endIcon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,29 @@
|
|||||||
import type { ITextFieldProps } from "../model/TextField.d"
|
import type { ITextFieldProps } from "../model/TextField.d"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { Text, TextField as RadixTextField } from "@radix-ui/themes"
|
|
||||||
import { forwardRef } from "react"
|
import { forwardRef } from "react"
|
||||||
|
|
||||||
|
import { TextField as RadixTextField, Text } from "@radix-ui/themes"
|
||||||
|
|
||||||
export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
|
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 htmlFor={id}>
|
||||||
{label && (
|
{label && (
|
||||||
<Text as="div" size="2" mb="1" weight="medium">
|
<Text as="div" size="2" mb="1" weight="medium">
|
||||||
@@ -15,14 +33,25 @@ export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<RadixTextField.Root
|
<RadixTextField.Root
|
||||||
id={id}
|
|
||||||
ref={ref}
|
|
||||||
size={size}
|
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-describedby={undertitle ? `${id}-undertitle` : undefined}
|
||||||
aria-invalid={error}
|
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 && (
|
{undertitle && (
|
||||||
<Text as="p" size="1" color="gray" mt="1" id={`${id}-undertitle`}>
|
<Text as="p" size="1" color="gray" mt="1" id={`${id}-undertitle`}>
|
||||||
{undertitle}
|
{undertitle}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from "./Badge"
|
|||||||
export * from "./Button"
|
export * from "./Button"
|
||||||
export * from "./Card"
|
export * from "./Card"
|
||||||
export * from "./Checkbox"
|
export * from "./Checkbox"
|
||||||
|
export * from "./CircularProgress"
|
||||||
export * from "./Form"
|
export * from "./Form"
|
||||||
export * from "./TextField"
|
export * from "./TextField"
|
||||||
export * from "./Modal"
|
export * from "./Modal"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
.root {
|
.root {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background-color: variables.$color-white;
|
background-color: variables.$color-white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenPath {
|
.screenPath {
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
|
||||||
import { Menu as MenuIcon } from "lucide-react"
|
import { Menu as MenuIcon } from "lucide-react"
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent, useState } from "react"
|
||||||
|
|
||||||
|
import { NavigationDrawer } from "@entities/NavigationDrawer"
|
||||||
|
import { UserDropdown } from "@entities/UserDropdown"
|
||||||
|
import { useAppSelector } from "@shared/hooks/useAppSelector"
|
||||||
import { Button } from "@shared/ui"
|
import { Button } from "@shared/ui"
|
||||||
|
|
||||||
import { IHeaderProps } from "../model/Header.d"
|
import { IHeaderProps } from "../model/Header.d"
|
||||||
import styles from "./Header.module.scss"
|
import styles from "./Header.module.scss"
|
||||||
|
|
||||||
export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
|
export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
|
||||||
|
const userData = useAppSelector((state) => state.user.user)
|
||||||
|
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root} data-testid="Header">
|
<>
|
||||||
|
<header className={styles.root} data-testid="Header">
|
||||||
<div className={styles.start}>
|
<div className={styles.start}>
|
||||||
<Button variant="icon">
|
<Button variant="icon" onClick={() => setIsDrawerOpen(true)}>
|
||||||
<MenuIcon size={24} />
|
<MenuIcon size={24} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className={styles.screenPath}>
|
<div className={styles.screenPath}>
|
||||||
@@ -21,6 +31,20 @@ export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
|
|||||||
<h3 className={styles.screenName}>Projects</h3>
|
<h3 className={styles.screenName}>Projects</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.end}>
|
||||||
|
<UserDropdown user={userData} />
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<NavigationDrawer
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
label: "Проекты",
|
||||||
|
path: "/projects",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./ui/ProjectsHeader"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface IProjectsHeaderProps {
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { JSX } from "react"
|
||||||
|
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
import { Separator } from "@radix-ui/themes"
|
||||||
|
|
||||||
|
import { Button, TextField } from "@shared/ui"
|
||||||
|
|
||||||
|
import { IProjectsHeaderProps } from "../model/ProjectsHeader.d"
|
||||||
|
import styles from "./ProjectsHeader.module.scss"
|
||||||
|
|
||||||
|
export const ProjectsHeader: FunctionComponent<
|
||||||
|
IProjectsHeaderProps
|
||||||
|
> = (): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<div className={styles.root} data-testid="ProjectsHeader">
|
||||||
|
<TextField
|
||||||
|
id="project-search-field"
|
||||||
|
placeholder="Поиск проектов..."
|
||||||
|
size={"2"}
|
||||||
|
startIcon={<SearchIcon size={16} />}
|
||||||
|
/>
|
||||||
|
<Separator orientation={"vertical"} className={styles.separator} />
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<Button variant="outline" size="md">
|
||||||
|
Все
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="md">
|
||||||
|
В обработке
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="md">
|
||||||
|
Завершенные
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user