This commit is contained in:
Pavel Torbeev 2023-08-26 17:46:38 +03:00
parent 11c5dbab3b
commit 4e4a66108c
132 changed files with 4479 additions and 99 deletions

View File

@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@seznam/compose-react-refs": "^1.0.6",
"@tanstack/react-query": "^4.33.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
@ -10,11 +12,22 @@
"@types/node": "^16.7.13",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"axios": "^1.4.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"focus-visible": "^5.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.45.4",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
"sanitize.css": "^13.0.0",
"sass": "^1.66.1",
"ts-key-enum": "^2.0.12",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
"web-vitals": "^2.1.0",
"zustand": "^4.4.1"
},
"scripts": {
"start": "react-scripts start",
@ -39,5 +52,9 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/date-fns": "^2.6.0",
"@types/react-helmet": "^6.1.6"
}
}

12
src/.prettierrc.js Normal file
View File

@ -0,0 +1,12 @@
module.exports = {
arrowParens: 'always',
bracketSameLine: true,
bracketSpacing: true,
endOfLine: 'auto',
printWidth: 120,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'none',
useTabs: false
};

View File

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,26 +0,0 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;

View File

@ -0,0 +1,36 @@
import {Answer, PitchDeck} from './types';
import {DECKS_API_URL, QUESTION_API_URL, QUESTION_PARAM_DECK_ID, QUESTION_PARAM_QUESTION_ID} from './urlKeys';
import {MutationConfig, queryClient} from '../../lib/react-query';
import {useMutation} from '@tanstack/react-query';
import {QUERY_KEY_ANSWER} from './queryKeys';
import { axios } from '../../lib/axios';
export type CreateAnswerDTO = {
deckId: number;
questionId: number;
answer: any;
};
export type CreateAnswerResponse = Answer;
export const createAnswer = (data: CreateAnswerDTO): Promise<CreateAnswerResponse> => {
const path = QUESTION_API_URL
.replace(`:${QUESTION_PARAM_DECK_ID}`, String(data.deckId))
.replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(data.questionId)) + '/'
return axios.post(path, { answer: data.answer });
};
type UseCreateAnswerOptions = {
config?: MutationConfig<typeof createAnswer>;
};
export const useCreateAnswer = ({ config }: UseCreateAnswerOptions = {}) => {
return useMutation({
onMutate: async () => {
await queryClient.cancelQueries([QUERY_KEY_ANSWER]);
},
...config,
mutationFn: createAnswer
});
};

View File

@ -0,0 +1,29 @@
import {PitchDeck} from './types';
import {DECKS_API_URL} from './urlKeys';
import {MutationConfig, queryClient} from '../../lib/react-query';
import {useMutation} from '@tanstack/react-query';
import {QUERY_KEY_DECKS} from './queryKeys';
import { axios } from '../../lib/axios';
export type CreateDeckDTO = Pick<PitchDeck, 'description'>;
export type CreateDeckResponse = PitchDeck;
export const createDeck = (data: CreateDeckDTO): Promise<CreateDeckResponse> => {
return axios.post(DECKS_API_URL, data);
};
type UseCreateDeckOptions = {
config?: MutationConfig<typeof createDeck>;
};
export const useCreateDeck = ({ config }: UseCreateDeckOptions = {}) => {
return useMutation({
onMutate: async () => {
await queryClient.cancelQueries([QUERY_KEY_DECKS]);
},
...config,
mutationFn: createDeck
});
};

View File

@ -0,0 +1,31 @@
import {Question} from './types';
import {axios} from '../../lib/axios';
import {FIRST_QUESTION_API_URL, FIRST_QUESTION_PARAM} from './urlKeys';
import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query';
import {useQuery} from '@tanstack/react-query';
import {QUERY_KEY_FIRST_QUESTION} from './queryKeys';
export type GetFirstQuestionResponse = Question;
export const getFirstQuestion = ({ deckId }: { deckId: number; }): Promise<GetFirstQuestionResponse> => {
return axios.get(FIRST_QUESTION_API_URL.replace(`:${FIRST_QUESTION_PARAM}`, String(deckId)));
};
type QueryFnType = typeof getFirstQuestion;
type UseFirstQuestionOptions = {
deckId: number;
config?: QueryConfig<QueryFnType>;
};
export const useFirstQuestion = ({ deckId, config }: UseFirstQuestionOptions) => {
return useQuery<ExtractFnReturnType<QueryFnType>>({
...config,
queryKey: [QUERY_KEY_FIRST_QUESTION, deckId],
queryFn: async () => {
const process = await getFirstQuestion({ deckId });
return process;
},
});
};

View File

@ -0,0 +1,38 @@
import {Question} from './types';
import {axios} from '../../lib/axios';
import {
QUESTION_API_URL,
QUESTION_PARAM_DECK_ID,
QUESTION_PARAM_QUESTION_ID,
} from './urlKeys';
import {ExtractFnReturnType, QueryConfig} from '../../lib/react-query';
import {useQuery} from '@tanstack/react-query';
import {QUERY_KEY_QUESTION} from './queryKeys';
export type GetQuestionResponse = Question;
export const getQuestion = ({ deckId, questionId }: { deckId: number; questionId: number; }): Promise<GetQuestionResponse> => {
return axios.get(
QUESTION_API_URL
.replace(`:${QUESTION_PARAM_DECK_ID}`, String(deckId))
.replace(`:${QUESTION_PARAM_QUESTION_ID}`, String(questionId))
);
};
type QueryFnType = typeof getQuestion;
type UseQuestionOptions = {
deckId: number;
questionId: number;
config?: QueryConfig<QueryFnType>;
};
export const useQuestion = ({ deckId, questionId, config }: UseQuestionOptions) => {
return useQuery<ExtractFnReturnType<QueryFnType>>({
...config,
queryKey: [QUERY_KEY_QUESTION, deckId, questionId],
queryFn: async () => {
return await getQuestion({ deckId, questionId });
},
});
};

6
src/api/deck/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from './types';
export * from './urlKeys';
export * from './queryKeys';
export * from './createDeck';
export * from './getFirstQuestion';

View File

@ -0,0 +1,4 @@
export const QUERY_KEY_DECKS = 'decks';
export const QUERY_KEY_FIRST_QUESTION = 'firstQuestion';
export const QUERY_KEY_QUESTION = 'question';
export const QUERY_KEY_ANSWER = 'answer';

45
src/api/deck/types.ts Normal file
View File

@ -0,0 +1,45 @@
export enum EntityType {
text = 'text', //
number = 'number', //
range = 'range', //
multiple_range = 'multiple_range', //
select = 'select', //
link = 'link', // добавить валидацию
date = 'date', // добавить правильную установку хинта
photo = 'photo',
multiple_photo = 'multiple_photo',
photo_description = 'photo_description',
multiple_link_description = 'multiple_link_description',
multiple_photo_description = 'multiple_photo_description',
multiple_links = 'multiple_links',
multiple_date_description = 'multiple_date_description', //
text_array = 'text_array', // используется только в подсказке
cards = 'cards', // используется только в подсказке
}
export type PitchDeck = {
id: number;
name: string;
description?: string;
questions?: any[];
};
export type Hint = {
type: EntityType;
value: any;
}
export type Question = {
id: number;
text: string;
type: EntityType;
hint: Hint | false;
next_id: number;
params: { [key: string]: any } | null;
};
export type Answer = {
answer: any;
deck: number;
question: number;
}

9
src/api/deck/urlKeys.ts Normal file
View File

@ -0,0 +1,9 @@
export const DECKS_API_URL = '/decks/';
export const FIRST_QUESTION_PARAM = 'deckId';
export const FIRST_QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}`;
export const QUESTION_PARAM_DECK_ID = 'deckId';
export const QUESTION_PARAM_QUESTION_ID = 'questionId';
export const QUESTION_API_URL = `/decks/question/:${FIRST_QUESTION_PARAM}/:${QUESTION_PARAM_QUESTION_ID}`;

15
src/app/App.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import {AppRoutes} from './routes';
export function App() {
return (
<>
<Helmet title={"Pitch Deck"}>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</Helmet>
<AppRoutes />
</>
);
}

13
src/app/AppProvider.tsx Normal file
View File

@ -0,0 +1,13 @@
import {FC, PropsWithChildren} from 'react';
import {BrowserRouter} from 'react-router-dom';
import {ReactQueryProvider} from './providers';
export const AppProvider: FC<PropsWithChildren> = ({ children }) => {
return (
<BrowserRouter>
<ReactQueryProvider>
{children}
</ReactQueryProvider>
</BrowserRouter>
)
}

2
src/app/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './App';
export * from './AppProvider';

View File

@ -0,0 +1 @@
export * from './react-query';

View File

@ -0,0 +1,11 @@
import {ReactFCC} from '../../utils/ReactFCC';
import {QueryClientProvider} from '@tanstack/react-query';
import {queryClient} from '../../lib/react-query';
export const ReactQueryProvider: ReactFCC = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}

View File

@ -0,0 +1,13 @@
import {Route, Routes} from 'react-router-dom';
import {ChatPage} from '../../pages/chat';
import {CHAT_PAGE_ROUTE} from './routes';
export const AppRoutes = () => {
return (
<Routes>
<Route path={CHAT_PAGE_ROUTE} element={<ChatPage />} />
</Routes>
);
};

2
src/app/routes/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './AppRoutes';
export * from './routes';

1
src/app/routes/routes.ts Normal file
View File

@ -0,0 +1 @@
export const CHAT_PAGE_ROUTE = `/chat`;

View File

@ -0,0 +1,153 @@
$font-family-raleway: 'Raleway';
$font-family-ibm-plex-mono: 'IBM Plex Mono';
$font-family-default: system-ui, /* macOS 10.11-10.12 */ -apple-system, /* Windows 6+ */ 'Segoe UI',
/* Android 4+ */ 'Roboto', /* Ubuntu 10.10+ */ 'Ubuntu', /* Gnome 3+ */ 'Cantarell', /* KDE Plasma 5+ */ 'Noto Sans',
/* fallback */ sans-serif, /* macOS emoji */ 'Apple Color Emoji', /* Windows emoji */ 'Segoe UI Emoji',
/* Windows emoji */ 'Segoe UI Symbol', /* Linux emoji */ 'Noto Color Emoji';
// Моноширинный набор шрифтов.
$font-family-mono: 'Menlo', 'Consolas', 'Roboto Mono', 'Ubuntu Monospace', 'Noto Mono', 'Oxygen Mono', 'Liberation Mono',
monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
$font-size-10: 10px;
$font-size-12: 12px;
$font-size-14: 14px;
$font-size-16: 16px;
$font-size-18: 18px;
$font-size-24: 24px;
$font-size-32: 32px;
$font-size-56: 56px;
$font-weight-400: 400;
$font-weight-500: 500;
$font-weight-600: 600;
$font-weight-700: 700;
$line-height-16: 16px;
$line-height-20: 20px;
$line-height-24: 24px;
$line-height-32: 32px;
$line-height-40: 40px;
$line-height-67: 67px;
@mixin text-header-h1 {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-56;
font-weight: $font-weight-700;
line-height: $line-height-67;
}
@mixin text-header-h2 {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-32;
font-weight: $font-weight-700;
line-height: $line-height-40;
}
@mixin text-header-h3 {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-24;
font-weight: $font-weight-700;
line-height: $line-height-32;
}
@mixin text-header-h4 {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-18;
font-weight: $font-weight-700;
line-height: $line-height-24;
}
@mixin text-body-l-medium {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-16;
font-weight: $font-weight-600;
line-height: $line-height-20;
}
@mixin text-body-l-regular {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-16;
font-weight: $font-weight-400;
line-height: $line-height-20;
}
@mixin text-body-m-medium {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-14;
font-weight: $font-weight-600;
line-height: $line-height-20;
}
@mixin text-body-m-regular {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-14;
font-weight: $font-weight-400;
line-height: $line-height-20;
}
@mixin text-body-s-medium {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-12;
font-weight: $font-weight-600;
line-height: $line-height-16;
}
@mixin text-body-s-regular {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-12;
font-weight: $font-weight-400;
line-height: $line-height-16;
}
@mixin text-caption-m-medium {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-14;
font-weight: $font-weight-500;
line-height: $line-height-20;
}
@mixin text-caption-m-regular {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-14;
font-weight: $font-weight-400;
line-height: $line-height-20;
}
@mixin text-caption-s-medium {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-10;
font-weight: $font-weight-500;
line-height: $line-height-16;
}
@mixin text-caption-s-regular {
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-10;
font-weight: $font-weight-400;
line-height: $line-height-16;
}
@mixin text-caption-all-caps {
letter-spacing: 0.04em;
text-transform: uppercase;
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-12;
font-weight: $font-weight-600;
line-height: $line-height-20;
}
@mixin text-programming-code-medium {
font-family: $font-family-ibm-plex-mono, $font-family-mono;
font-size: $font-size-12;
font-weight: $font-weight-400;
line-height: $line-height-20;
}
@mixin text-programming-code-regular {
font-family: $font-family-ibm-plex-mono, $font-family-mono;
font-size: $font-size-12;
font-weight: $font-weight-400;
line-height: $line-height-16;
}

View File

@ -0,0 +1,71 @@
$breakpoint-mobile-small: 320px;
$breakpoint-mobile-large: 375px;
$breakpoint-tablet-small: 768px;
$breakpoint-tablet-large: 1024px;
$breakpoint-desktop-small: 1280px;
$breakpoint-desktop-medium: 1440px;
$breakpoint-desktop-large: 1920px;
@mixin media-up($breakpoint) {
@if $breakpoint == mobile-small {
@media screen and (min-width: $breakpoint-mobile-small) {
@content;
}
} @else if $breakpoint == mobile-large {
@media screen and (min-width: $breakpoint-mobile-large) {
@content;
}
} @else if $breakpoint == tablet-small {
@media screen and (min-width: $breakpoint-tablet-small) {
@content;
}
} @else if $breakpoint == tablet-large {
@media screen and (min-width: $breakpoint-tablet-large) {
@content;
}
} @else if $breakpoint == desktop-small {
@media screen and (min-width: $breakpoint-desktop-small) {
@content;
}
} @else if $breakpoint == desktop-medium {
@media screen and (min-width: $breakpoint-desktop-medium) {
@content;
}
} @else if $breakpoint == desktop-large {
@media screen and (min-width: $breakpoint-desktop-large) {
@content;
}
}
}
@mixin media-down($breakpoint) {
@if $breakpoint == mobile-small {
@media screen and (max-width: $breakpoint-mobile-small - 1) {
@content;
}
} @else if $breakpoint == mobile-large {
@media screen and (max-width: $breakpoint-mobile-large - 1) {
@content;
}
} @else if $breakpoint == tablet-small {
@media screen and (max-width: $breakpoint-tablet-small - 1) {
@content;
}
} @else if $breakpoint == tablet-large {
@media screen and (max-width: $breakpoint-tablet-large - 1) {
@content;
}
} @else if $breakpoint == desktop-small {
@media screen and (max-width: $breakpoint-desktop-small - 1) {
@content;
}
} @else if $breakpoint == desktop-medium {
@media screen and (max-width: $breakpoint-desktop-medium - 1) {
@content;
}
} @else if $breakpoint == desktop-large {
@media screen and (max-width: $breakpoint-desktop-large - 1) {
@content;
}
}
}

View File

@ -0,0 +1,71 @@
/* --- surface --- */
$color-surface-primary: #141414;
/* --- on-surface --- */
$color-on-surface-dark-100: #1d1d1d;
$color-on-surface-dark-200: #252525;
$color-on-surface-dark-300: #2d2d2d;
$color-on-surface-dark-400: #3e3e3e;
$color-on-surface-light-400: #5e6166;
$color-on-surface-light-300: #868a92;
$color-on-surface-light-200: #c5c6ca;
$color-on-surface-light-100: #fafafa;
$color-on-surface-quaternary: #2b2b2b;
/* --- text --- */
$color-text-primary: #fafafa;
$color-text-secondary: #c5c6ca;
$color-text-tertiary: #868a92;
$color-text-quaternary: #5e6166;
$color-text-dark: #2b2b2b;
$color-text-brand: #8e85e5;
/* --- brand --- */
$color-brand-primary: #695fcf;
$color-brand-hover: #9288f8;
$color-brand-disabled: #49428e;
/* --- system --- */
$color-system-link: #7f9ef3;
$color-system-success: #10b981;
$color-system-warning: #f59e0b;
$color-system-error: #f43f5e;
$color-system-link-weak: #142144;
$color-system-success-weak: #042c1f;
$color-system-warning-weak: #3d2907;
$color-system-error-weak: #2d1015;
$color-system-link-hover: #a8c1f8;
/* --- accent --- */
$color-accent-blue-100: #7f9ef3;
$color-accent-blue-200: #2b4acb;
$color-accent-blue-300: #203175;
$color-accent-blue-400: #191e40;
$color-accent-pink-100: #f37fb7;
$color-accent-pink-200: #cb2b83;
$color-accent-pink-300: #75204f;
$color-accent-pink-400: #40162f;
$color-accent-purple-100: #ab7ae0;
$color-accent-purple-200: #642ab5;
$color-accent-purple-300: #3e2069;
$color-accent-purple-400: #21183b;
$color-accent-green-100: #8fd460;
$color-accent-green-200: #49aa19;
$color-accent-green-300: #306317;
$color-accent-green-400: #1d3712;
$color-accent-cyan-100: #58d1c9;
$color-accent-cyan-200: #13a8a8;
$color-accent-cyan-300: #146262;
$color-accent-cyan-400: #163b2e;
$color-accent-orange-100: #f3b765;
$color-accent-orange-200: #d87a16;
$color-accent-orange-300: #7c4a15;
$color-accent-orange-400: #442a11;
$color-accent-yellow-100: #f3ea62;
$color-accent-yellow-200: #d8bd14;
$color-accent-yellow-300: #7c6e14;
$color-accent-yellow-400: #443611;
$color-accent-red-100: #f37370;
$color-accent-red-200: #d32029;
$color-accent-red-300: #791a1f;
$color-accent-red-400: #421716;

48
src/app/styles/fonts.scss Normal file
View File

@ -0,0 +1,48 @@
@import './baseText';
$fonts-path: '../../assets/fonts' !default;
@font-face {
font-family: $font-family-raleway;
src: url('#{$fonts-path}/Raleway/Raleway-Regular.woff2') format('woff2'),
url('#{$fonts-path}/Raleway/Raleway-Regular.woff') format('woff');
font-weight: $font-weight-400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: $font-family-raleway;
src: url('#{$fonts-path}/Raleway/Raleway-Medium.woff2') format('woff2'),
url('#{$fonts-path}/Raleway/Raleway-Medium.woff') format('woff');
font-weight: $font-weight-500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: $font-family-raleway;
src: url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff2') format('woff2'),
url('#{$fonts-path}/Raleway/Raleway-SemiBold.woff') format('woff');
font-weight: $font-weight-600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: $font-family-raleway;
src: url('#{$fonts-path}/Raleway/Raleway-Bold.woff2') format('woff2'),
url('#{$fonts-path}/Raleway/Raleway-Bold.woff') format('woff');
font-weight: $font-weight-700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: $font-family-ibm-plex-mono;
src: url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff2') format('woff2'),
url('#{$fonts-path}/IBM_Plex_Mono/IBMPlexMono-Regular.woff') format('woff');
font-weight: $font-weight-400;
font-style: normal;
font-display: swap;
}

37
src/app/styles/index.scss Normal file
View File

@ -0,0 +1,37 @@
@import './vars';
@import './fonts';
html {
color: $color-text-primary;
//background-color: $color-surface-primary;
background-color: black;
//background-color: $color-on-surface-dark-100;
font-family: $font-family-raleway, $font-family-default;
font-size: $font-size-16;
line-height: $line-height-20;
}
html,
body {
width: 100%;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'pnum' on, 'lnum' on;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
}
a {
text-decoration: none;
color: inherit;
}

220
src/app/styles/mixins.scss Normal file
View File

@ -0,0 +1,220 @@
@use 'sass:list';
/* positions */
@mixin position-absolute-full-screen {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
/* size */
@mixin size($size) {
min-width: $size;
max-width: $size;
min-height: $size;
max-height: $size;
}
/* placeholder */
@mixin placeholder {
&::placeholder {
@content;
}
}
/* buttons */
@mixin reset-button {
align-items: flex-start;
margin: 0;
padding: 0;
text-align: center;
text-decoration: none;
text-indent: 0;
letter-spacing: inherit;
word-spacing: inherit;
text-transform: none;
color: inherit;
border: none;
outline: none;
background: none;
text-shadow: none;
font: inherit;
line-height: inherit;
cursor: default;
box-sizing: border-box;
&:focus {
outline: none;
}
}
@mixin reset-default-input {
padding: unset;
border: none;
&:focus {
outline: none;
outline-offset: initial;
}
}
/* Хак для того, чтобы убрать браузерные autofill стили */
@mixin remove-autofill-style {
&:-webkit-autofill {
/* stylelint-disable-next-line */
-webkit-background-clip: text;
}
}
/* links */
@mixin link-reset {
all: unset;
}
@mixin focus-visible {
/* stylelint-disable-next-line */
&:global(.focus-visible) {
@content;
}
}
@mixin line-clamp($n) {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: $n;
-webkit-box-orient: vertical;
}
@mixin hide-default-input {
position: absolute;
z-index: -1;
top: 0;
left: 0;
width: 0;
height: 0;
opacity: 0;
}
/* stylelint-disable order/order */
@mixin transition($properties...) {
$declarations: ();
@each $declaration in $properties {
$duration: 0.2s;
$delay: false;
$timing-function: ease-out;
$property: all;
@if type-of($declaration) == 'map' {
$duration: if(map_get($declaration, 'duration'), #{map_get($declaration, 'duration')}, $duration);
$delay: if(map_get($declaration, 'delay'), #{map_get($declaration, 'delay')}, $delay);
$timing-function: if(
map_get($declaration, 'timing-function'),
#{map_get($declaration, 'timing-function')},
$timing-function
);
$property: if(map_get($declaration, 'property'), #{map_get($declaration, 'property')}, $property);
} @else {
$property: $declaration;
}
$delay-value: if($delay, ' ' + $delay, '');
$declarations: list.append(
$declarations,
#{$property + ' ' + $duration + $delay-value + ' ' + $timing-function},
comma
);
}
transition: $declarations;
}
/* stylelint-enable order/order */
@mixin flex {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: flex-start;
}
@mixin flex-center {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
@mixin flex-middle {
display: flex;
flex-direction: row;
align-items: center;
}
@mixin flex-col {
display: flex;
flex-direction: column;
}
@mixin flex-col-middle {
display: flex;
flex-direction: column;
align-items: center;
}
@mixin text-overflow {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
/* scrollbar */
$scrollbar-width: 12px;
@mixin scrollbar {
&::-webkit-scrollbar {
width: $scrollbar-width;
height: $scrollbar-width;
}
&::-webkit-scrollbar-thumb {
border: 4px solid transparent;
border-radius: 6px;
background-color: $color-on-surface-light-400;
background-clip: content-box;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $color-on-surface-light-300;
}
}
@mixin hide-scrollbar {
overflow-y: auto;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
}

View File

@ -0,0 +1,6 @@
$radius-none: 0;
$radius-small: 4px;
$radius-medium: 8px;
$radius-large: 12px;
$radius-large-x: 16px;
$radius-large-xx: 24px;

View File

@ -0,0 +1,7 @@
$shadow-dropdown: 0 1px 24px 0 rgb(0 0 0 / 40%);
$shadow-hover: 0 4px 24px 0 rgb(0 0 0 / 24%);
$shadow-bottom-item: 0 -2px 12px 0 rgb(0 0 0 / 25%);
$shadow-top: 0 -4px 1px rgb(20 20 20 / 8%), 0 -12px 6px rgb(20 20 20 / 16%), 0 -8px 20px rgb(20 20 20 / 24%);
$shadow-left: -4px 0 1px rgb(20 20 20 / 8%), -12px 0 6px rgb(20 20 20 / 16%), -8px 0 20px rgb(20 20 20 / 24%);
$shadow-right: 4px 0 1px rgb(20 20 20 / 8%), 12px 0 6px rgb(20 20 20 / 16%), 8px 0 20px rgb(20 20 20 / 24%);
$shadow-short: 0 6px 16px -12px rgb(23 40 77 / 6%);

View File

@ -0,0 +1,12 @@
$spacing-small-4x: 4px;
$spacing-small-3x: 8px;
$spacing-small-2x: 12px;
$spacing-small-x: 16px;
$spacing-small: 24px;
$spacing-medium: 32px;
$spacing-medium-x: 40px;
$spacing-large: 48px;
$spacing-large-x: 64px;
$spacing-large-2x: 72px;
$spacing-large-3x: 96px;
$spacing-large-4x: 124px;

8
src/app/styles/vars.scss Normal file
View File

@ -0,0 +1,8 @@
@import 'colors';
@import 'baseText';
@import 'breakpoints';
@import 'mixins';
@import 'radius';
@import 'spacing';
@import 'z-index';
@import 'shadow';

View File

@ -0,0 +1,11 @@
$z-index-behind: -1;
$z-index-default: 0;
$z-index-primary: 1;
$z-index-secondary: 2;
$z-index-dropdown-menu: 10;
$z-index-sidebar: 100;
$z-index-editor: 100;
$z-index-overlay: 101;
$z-index-modal: 1000;
$z-index-notification: 1001;
$z-index-max: 99999;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.834 9.99983H4.16731" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6673 14.1665L15.834 9.99983" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.6673 5.83317L15.834 9.99983" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@ -0,0 +1,119 @@
@import 'src/app/styles/vars';
$button-border-width: 2px;
.Button {
@include text-body-m-medium;
@include transition(color, background-color, border-color);
position: relative;
display: flex;
align-items: center;
width: max-content;
padding: 0 ($spacing-small - $button-border-width);
text-decoration: none;
color: $color-text-primary;
border: $button-border-width solid transparent;
//border-radius: $radius-small;
border-radius: $radius-large-x;
background-color: rgb(255 255 255 / 0%);
box-shadow: none;
user-select: none;
cursor: pointer;
box-sizing: border-box;
appearance: none;
&,
&:hover,
&:focus {
outline: none;
}
}
.Button_disabled {
pointer-events: none;
}
.Button:focus-visible {
border-color: $color-brand-hover;
}
.Button_hasLeft {
padding-left: $spacing-small-x - $button-border-width;
}
.Button_hasRight {
padding-right: $spacing-small-x - $button-border-width;
}
.Button__content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
text-align: center;
}
.Button__loader,
.Button__contentLeft,
.Button__contentRight {
display: flex;
align-items: center;
}
.Button__contentLeft {
margin-right: $spacing-small-3x;
}
.Button__loader,
.Button__contentRight {
margin-left: $spacing-small-3x;
}
.Button_size_small {
height: 32px;
}
.Button_size_medium {
height: 40px;
}
.Button_size_large {
height: 48px;
}
.Button_variant_primary {
background-color: $color-brand-primary;
&.Button_hovered,
&:hover {
background-color: $color-brand-hover;
}
&.Button_disabled {
color: $color-text-tertiary;
background-color: $color-brand-disabled;
}
}
.Button_variant_secondary {
background-color: $color-on-surface-dark-300;
&.Button_hovered,
&:hover {
background-color: $color-on-surface-dark-400;
}
&.Button_disabled {
color: $color-text-quaternary;
}
}
.Button_stretch_fit {
width: fit-content;
}
.Button_stretch_fill {
width: 100%;
}

View File

@ -0,0 +1,148 @@
import React, { ElementType, useMemo } from 'react';
import clsx from 'clsx';
import s from './Button.module.scss';
import {PolyExtends} from '../../utils/types';
export enum ButtonSize {
small = 'small',
medium = 'medium',
large = 'large'
}
export enum ButtonVariant {
primary = 'primary',
secondary = 'secondary',
tertiary = 'tertiary'
}
export enum ButtonStretch {
fit = 'fit',
fill = 'fill'
}
export const ButtonDefaultComponent = 'button' as const;
export type ButtonDefaultComponentType = typeof ButtonDefaultComponent;
export interface ButtonSelfProps<ComponentType extends ElementType = ButtonDefaultComponentType> {
/**
* Размер ("small", "medium", "large")
*/
size?: ButtonSize;
/**
* Вариант оформления ("primary", "secondary", "tertiary")
*/
variant?: ButtonVariant;
/**
* Заблокированное состояние
*/
disabled?: boolean;
/**
* Проп для контролируемого включения состояния ховера
*/
hovered?: boolean;
/**
* Цвет фона в hex-формате
*/
color?: string;
/**
* Состояние со спиннером
*/
isLoading?: boolean;
/**
* Потомки компонента
*/
children?: React.ReactNode;
/**
* Реф на корневой DOM-элемент
*/
innerRef?: React.ComponentProps<ComponentType>['ref'];
/**
* Дополнительные css-классы элементов
*/
classes?: {
content?: string;
contentLeft?: string;
contentRight?: string;
text?: string;
icon?: string;
};
/**
* Вариант растягивания кнопки ("fit", "fill")
*/
stretch?: ButtonStretch;
}
export type ButtonProps<ComponentType extends ElementType = ButtonDefaultComponentType> = PolyExtends<
ComponentType,
ButtonSelfProps<ComponentType>,
React.ComponentProps<ComponentType>
>;
export function Button<ComponentType extends ElementType = ButtonDefaultComponentType>({
component,
className,
style,
size = ButtonSize.medium,
variant = ButtonVariant.primary,
disabled,
hovered,
children,
leftIcon,
right,
left,
rightIcon,
isLoading,
color,
innerRef,
classes,
stretch,
...props
}: ButtonProps<ComponentType>) {
// Чтобы ts не проверял корректность передачи пропов
const Component = (component || ButtonDefaultComponent) as React.ElementType;
const buttonProps = useMemo(() => {
if (Component === 'button') {
return { type: 'button' };
}
return undefined;
}, [Component]);
const hasLeft = left || leftIcon;
const hasRight = right || rightIcon;
return (
<Component
ref={innerRef}
className={clsx(
s.Button,
{
[s[`Button_size_${size}`]]: size,
[s[`Button_variant_${variant}`]]: variant,
[s[`Button_stretch_${stretch}`]]: stretch,
[s.Button_disabled]: disabled,
[s.Button_hovered]: hovered,
[s.Button_hasLeft]: hasLeft,
[s.Button_hasRight]: hasRight
},
className
)}
style={{
color,
...style
}}
disabled={disabled}
{...buttonProps}
{...props}>
<div className={clsx(s.Button__content, classes?.content)}>
<div className={classes?.text}>{children}</div>
{/*{isLoading && <Loader className={s.Button__loader} size={LoaderSize.small} />}*/}
</div>
</Component>
);
}
Button.Variant = ButtonVariant;
Button.Size = ButtonSize;

View File

@ -0,0 +1 @@
export * from './Button';

View File

@ -0,0 +1,69 @@
@import 'src/app/styles/vars';
.BaseCheckboxIcon {
@include transition(border, background-color);
position: relative;
display: flex;
flex: 0 0 16px;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1px solid $color-on-surface-light-300;
border-radius: $radius-small;
background-color: transparent;
box-sizing: border-box;
}
.BaseCheckboxIcon__icon {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
}
.BaseCheckboxIcon__icon > * {
@include transition(opacity, color);
color: $color-on-surface-light-100;
opacity: 0;
}
.BaseCheckboxIcon__icon_visible > * {
opacity: 1;
}
.BaseCheckboxIcon_disabled {
color: $color-accent-blue-100;
border-color: $color-accent-blue-300;
}
.BaseCheckboxIcon_checked {
border-color: transparent;
background-color: $color-accent-blue-200;
&.BaseCheckboxIcon_disabled {
background-color: $color-accent-blue-300;
}
}
.BaseCheckboxIcon_radio {
border-radius: 50%;
&.BaseCheckboxIcon_checked {
border: 4px solid $color-accent-blue-200;
background-color: $color-on-surface-light-100;
}
&.BaseCheckboxIcon_disabled {
border-color: $color-accent-blue-300;
}
&.BaseCheckboxIcon_checked.BaseCheckboxIcon_disabled {
background-color: $color-accent-blue-100;
}
}

View File

@ -0,0 +1,49 @@
import React, { FunctionComponent } from 'react';
import clsx from 'clsx';
import s from './BaseCheckboxIcon.module.scss';
import {DivPropsWithoutRef} from '../../../utils/types';
export interface BaseCheckboxIconProps extends DivPropsWithoutRef {
/**
* Состояние Checkbox: включен или выключен
*/
checked?: boolean;
/**
* Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя
*/
disabled?: boolean;
/**
* Свойство, устанавливающие тип radio для BaseCheckboxIcon
*/
radio?: boolean;
}
export const BaseCheckboxIcon: FunctionComponent<BaseCheckboxIconProps> = ({
className,
checked,
disabled,
radio,
children,
...props
}) => {
return (
<div
className={clsx(
s.BaseCheckboxIcon,
{
[s.BaseCheckboxIcon_radio]: radio,
[s.BaseCheckboxIcon_disabled]: disabled,
[s.BaseCheckboxIcon_checked]: checked
},
className
)}
{...props}>
<div
className={clsx(s.BaseCheckboxIcon__icon, {
[s.BaseCheckboxIcon__icon_disabled]: disabled,
[s.BaseCheckboxIcon__icon_visible]: checked
})}>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './BaseCheckboxIcon';

View File

@ -0,0 +1,81 @@
@import 'src/app/styles/vars';
.Checkbox {
position: relative;
display: inline-flex;
align-items: center;
vertical-align: middle;
user-select: none;
cursor: pointer;
&,
&:focus {
outline: none;
}
}
.Checkbox__input {
@include hide-default-input;
}
.Checkbox__label {
@include text-caption-m-regular;
@include transition(color);
display: inline-flex;
margin-left: $spacing-small-3x;
vertical-align: middle;
color: $color-text-primary;
}
.Checkbox__icon {
&::before {
@include transition(border-color);
position: absolute;
top: 50%;
left: 50%;
width: 32px;
height: 32px;
content: '';
border: 2px solid transparent;
border-radius: $radius-medium;
transform: translate(-50%, -50%);
}
}
.Checkbox__input:focus-visible + .Checkbox__icon {
&::before {
border-color: $color-brand-hover;
}
}
.Checkbox:hover .Checkbox__icon {
border-color: $color-accent-blue-100;
}
.Checkbox_disabled {
cursor: default;
pointer-events: none;
.Checkbox__label {
color: $color-text-tertiary;
}
}
.Checkbox_checked {
&:hover {
.Checkbox__icon:not(.Checkbox__icon_radio) {
background-color: $color-accent-blue-100;
}
.Checkbox__icon_radio {
border-color: $color-accent-blue-100;
}
}
}
.Checkbox_radio.Checkbox_checked.Checkbox_disabled:hover .BaseCheckboxIcon_radio {
border: 6px solid brown;
background-color: transparent;
}

View File

@ -0,0 +1,121 @@
import React, { ChangeEvent, FunctionComponent, ReactNode } from 'react';
import clsx from 'clsx';
import s from './Checkbox.module.scss';
import {InputPropsWithoutRef, IntrinsicPropsWithoutRef} from '../../utils/types';
import {BaseCheckboxIcon} from './BaseCheckboxIcon';
export interface ICheckboxProps extends Omit<InputPropsWithoutRef, 'label' | 'onChange'> {
/**
* Состояние Checkbox: включен или выключен
*/
checked?: boolean;
/**
* Неактивное состояние Checkbox - состояние, при котором компонент отображается, но недоступен для действий пользователя
*/
disabled?: boolean;
/**
* Если true, то компонент аналогичен компоненту Radio
*/
radio?: boolean;
/**
* Слот подписи
*/
label?: ReactNode;
/**
* Обработчик изменения состояния Checkbox. Принимает на вход новое значение состояния Checkbox (в случае, если checked, то новое значение - false, иначе - true) и ChangeEvent
*/
onChange?: (value: boolean, e: ChangeEvent<HTMLInputElement>) => void;
/**
* Слот для замены иконки чекбокса
*/
checkboxIcon?: React.ReactNode;
/**
* Дополнительные css-классы элементов
* * input класс элемента input
* * label класс лейбла
* * icon класс иконки чекбокса
*/
classes?: {
input?: string;
label?: string;
icon?: string;
};
/**
* Дополнительный css-класс корневого элемента
*/
className?: string;
/**
* Реф на корневой элемент
*/
innerRef?: React.Ref<HTMLLabelElement>;
/**
* Реф на input
*/
inputRef?: React.Ref<HTMLInputElement>;
/**
* Дополнительные пропы корневого элемента
*/
rootProps?: IntrinsicPropsWithoutRef<'label'>;
}
export const Checkbox: FunctionComponent<ICheckboxProps> = ({
className,
checked,
disabled,
radio,
label,
onChange,
style,
checkboxIcon,
classes,
innerRef,
inputRef,
rootProps,
...props
}) => {
const handleChange = onChange
? (e: ChangeEvent<HTMLInputElement>) => {
onChange(!checked, e);
}
: undefined;
return (
<label
ref={innerRef}
className={clsx(
s.Checkbox,
{
[s.Checkbox_radio]: radio,
[s.Checkbox_disabled]: disabled,
[s.Checkbox_checked]: checked
},
className
)}
style={style}
{...rootProps}>
<input
ref={inputRef}
type={radio ? 'radio' : 'checkbox'}
className={clsx(s.Checkbox__input, classes?.input)}
checked={checked}
disabled={disabled}
onChange={handleChange}
{...props}
/>
<BaseCheckboxIcon
className={clsx(
s.Checkbox__icon,
{
[s.Checkbox__icon_radio]: radio
},
classes?.icon
)}
disabled={disabled}
checked={checked}
radio={radio}>
{checkboxIcon}
</BaseCheckboxIcon>
{label && <div className={clsx(s.Checkbox__label, classes?.label)}>{label}</div>}
</label>
);
};

View File

@ -0,0 +1 @@
export * from './Checkbox';

View File

@ -0,0 +1,30 @@
import React from 'react';
import { useForm, UseFormReturn, SubmitHandler, UseFormProps, FieldValues } from 'react-hook-form';
type FormProps<TFormValues extends FieldValues> = {
className?: string;
onSubmit?: SubmitHandler<TFormValues>;
children: (methods: UseFormReturn<TFormValues>) => React.ReactNode;
options?: UseFormProps<TFormValues>;
id?: string;
innerRef?: React.Ref<HTMLFormElement>;
};
export const Form = <TFormValues extends FieldValues = FieldValues>({
onSubmit,
children,
className,
options,
id,
innerRef
}: FormProps<TFormValues>) => {
const methods = useForm<TFormValues>({ ...options });
return (
// <form className={className} onSubmit={onSubmit && methods.handleSubmit(onSubmit)} id={id} ref={innerRef}>
<>
{children(methods)}
</>
// </form>
);
};

View File

@ -0,0 +1 @@
export * from './Form';

View File

@ -0,0 +1,83 @@
@import 'src/app/styles/vars';
.Hint__container {
position: relative;
width: fit-content;
height: 44px;
@include media-down(tablet-small) {
width: 100%;
}
}
.Hint__container_hovered {
height: unset;
}
.Hint {
@include transition(background-color, color, width);
position: relative;
//display: inline-block;
padding: $spacing-small-2x;
border-radius: $radius-large-x;
background-color: $color-on-surface-light-400;
//margin-right: $spacing-small;
width: fit-content;
color: $color-text-secondary;
cursor: pointer;
&:not(.Hint_hovered) {
white-space: nowrap;
max-width: 500px;
overflow: hidden;
text-overflow: ellipsis;
@include media-down(tablet-small) {
width: 100%;
max-width: 100%;
}
}
}
.Hint_hovered {
width: 100%;
background-color: $color-brand-hover;
color: white;
}
.Hint__tip {
@include text-caption-m-medium;
position: absolute;
top: -4px;
right: -4px;
background-color: white;
z-index: 1;
padding: 2px 4px;
width: 16px;
height: 16px;
border-radius: 20px;
}
.Hint__button {
width: 100% !important;
margin-top: $spacing-small-2x;
}
.Hints {
@include flex;
flex-wrap: wrap;
gap: $spacing-small-x;
@include media-down(tablet-small) {
flex-direction: column;
}
}
.Hints_margin {
margin-bottom: $spacing-small;
}
.Hints__stub {
@include text-body-s-regular;
color: $color-text-secondary;
}

View File

@ -0,0 +1,72 @@
import clsx from 'clsx';
import s from './Hint.module.scss';
import {ReactFCC} from '../../utils/ReactFCC';
import {FC} from 'react';
import {useHover} from '../../hooks/useHover';
import {useIsMobile} from '../../hooks/useIsMobile';
import {useToggle} from '../../hooks/useToggle';
import {Button, ButtonVariant} from '../Button';
export interface HintProps {
/**
* Дополнительный css-класс
*/
className?: string;
children: string;
onClick?: () => void;
}
export const Hint: FC<HintProps> = (props) => {
const {children, className, onClick: onClickProp} = props;
const isMobile = useIsMobile();
const [hoveredMobile, { toggle }] = useToggle();
const { hovered: hoveredDesktop, ...hoverProps } = useHover();
const hintProps = isMobile ? {} : hoverProps;
const onClick = () => {
if (isMobile) {
toggle()
}
}
const hovered = isMobile ? hoveredMobile : hoveredDesktop;
return (
<div className={clsx(s.Hint__container, hovered && s.Hint__container_hovered)} onClick={() => {
if (!isMobile) {
onClickProp?.();
}
}}>
<div className={s.Hint__tip} />
<div className={clsx(s.Hint, className, {[s.Hint_hovered]: hovered})} {...hintProps} onClick={onClick}>
{children}
{isMobile && hovered && (
<Button variant={ButtonVariant.secondary} className={s.Hint__button} onClick={(e: any) => {
e.stopPropagation();
if (isMobile) {
onClickProp?.();
}
}}>Использовать</Button>
)}
</div>
</div>
);
};
export interface HintsContainerProps {
isLoading?: boolean;
margin?: boolean;
}
export const HintsContainer: ReactFCC<HintsContainerProps> = ({ children, isLoading, margin }) => {
return (
<div className={clsx(s.Hints, (children || isLoading) && margin && s.Hints_margin)}>{!isLoading ? children : (
<div className={s.Hints__stub}>Загрузка подсказок...</div>
)}</div>
)
}

View File

@ -0,0 +1 @@
export * from './Hint';

View File

@ -0,0 +1,130 @@
@import 'src/app/styles/vars';
.Input {
@include text-body-m-regular;
@include transition(border-color, box-shadow);
position: relative;
display: flex;
align-items: center;
height: 40px;
padding: 0 $spacing-small-x;
color: $color-text-primary;
overflow: hidden;
border: 1px solid transparent;
border-radius: $radius-small;
background-color: $color-on-surface-dark-400;
cursor: text;
box-sizing: border-box;
&:hover {
box-shadow: $shadow-hover;
}
}
.Input_error {
border-color: $color-system-error;
}
.Input_focus {
border-color: $color-brand-primary;
}
.Input_disabled {
color: $color-text-tertiary;
border-color: transparent;
background: $color-on-surface-dark-300;
cursor: default;
&:hover {
box-shadow: none;
}
}
.Input__input {
@include reset-default-input;
@include remove-autofill-style;
position: relative;
flex-grow: 1;
min-width: 0;
height: 100%;
&:placeholder-shown + .Input__clear {
display: none;
}
&[type='number'] {
appearance: textfield;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
caret-color: $color-text-primary;
-webkit-text-fill-color: $color-text-primary;
}
}
.Input__input_disabled {
cursor: default;
}
.Input__input::placeholder {
color: $color-text-tertiary;
opacity: 1;
}
.Input__icon {
display: flex;
flex: 0 0 auto;
align-items: center;
max-height: 100%;
color: $color-on-surface-light-300;
}
.Input__icon_active {
@include transition(color);
cursor: pointer;
&:hover {
color: $color-on-surface-light-200;
}
}
.Input__icon_left {
justify-content: flex-start;
margin-right: $spacing-small-3x;
margin-left: -$spacing-small-3x;
}
.Input__icon_right {
justify-content: flex-end;
margin-left: $spacing-small-3x;
&:last-child {
margin-right: -$spacing-small-3x;
}
}
.Input__icon_clear {
@include transition(color);
position: relative;
cursor: pointer;
&:hover {
color: $color-on-surface-light-200;
}
}
.Input_disabled .Input__icon_clear {
pointer-events: none;
}

View File

@ -0,0 +1,103 @@
import React, {ChangeEvent, useCallback, useRef, useState} from 'react';
import clsx from 'clsx';
import composeRefs from '@seznam/compose-react-refs';
import s from './Input.module.scss';
import {IntrinsicPropsWithoutRef} from '../../utils/types';
import {useDelegateFocus} from '../../hooks/useDelegateFocus';
import {useFocus} from '../../hooks/useFocus';
import {useLiveInput} from '../../hooks/useLiveInput';
import {UseFormRegisterReturn} from 'react-hook-form';
export interface InputProps extends Omit<IntrinsicPropsWithoutRef<'input'>, 'onClick'> {
/**
* Состояние ошибки
*/
error?: boolean;
/**
* Проп для контролируемого включения состояния фокуса
*/
focused?: boolean;
/**
* Ref на input-элемент
*/
inputRef?: React.Ref<HTMLInputElement>;
/**
* Обработчик нажатия на Input
*/
onClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
/**
* Дополнительные css-классы элементов:
* * root - внешний контейнер
* * input - элемент input
* * icon - иконки слева и справа Input
* * iconLeft - иконка слева Input
* * iconRight - иконка справа Input
*/
classes?: {
root?: string;
icon?: string;
iconLeft?: string;
iconRight?: string;
input?: string;
};
registration?: Partial<UseFormRegisterReturn>;
}
export type InputType = React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HTMLDivElement>>;
const InputForwardedRef = React.forwardRef<HTMLDivElement, InputProps>(
(
{
error,
focused: focusedProp,
className,
classes,
onClick,
onInput,
inputRef: inputRefProp,
style,
type,
value,
registration,
...inputProps
},
ref
) => {
const inputRef = useRef<HTMLInputElement>(null);
const delegateProps = useDelegateFocus<HTMLDivElement, HTMLInputElement>(inputRef, { onClick });
const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration });
return (
<div
ref={ref}
className={clsx(
s.Input,
{
[s.Input_focus]: focusedProp ?? focused,
[s.Input_error]: error,
[s.Input_disabled]: inputProps.disabled
},
className,
classes?.root
)}
style={style}
{...delegateProps}>
<input
type={type}
className={clsx(s.Input__input, { [s.Input__input_disabled]: inputProps.disabled }, classes?.input)}
autoComplete={'off'}
{...registration}
// @ts-ignore
ref={composeRefs(inputRef, inputRefProp, registration?.ref)}
{...inputProps}
{...focusProps}
/>
</div>
);
}
);
InputForwardedRef.displayName = 'Input';
export const Input: InputType = InputForwardedRef;

View File

@ -0,0 +1 @@
export * from './Input';

View File

@ -0,0 +1,29 @@
@import 'src/app/styles/vars';
//$message-radius: $radius-large;
$message-radius: $radius-large-xx;
.Message {
@include text-body-l-regular;
padding: $spacing-small-x;
box-shadow: $shadow-short;
white-space: pre-wrap;
}
.Message_variant_primary {
background-color: $color-brand-primary;
}
.Message_variant_secondary {
background-color: $color-on-surface-dark-400;
color: #d0d1d5;
}
.Message_type_right {
border-radius: $message-radius $message-radius 0 $message-radius;
}
.Message_type_left {
border-radius: $message-radius $message-radius $message-radius 0;
}

View File

@ -0,0 +1,33 @@
import clsx from 'clsx';
import s from './Message.module.scss';
import {ReactFCC} from '../../utils/ReactFCC';
export enum MessageVariant {
primary = 'primary',
secondary = 'secondary'
}
export enum MessageType {
right = 'right',
left = 'left'
}
export interface MessageProps {
/**
* Дополнительный css-класс
*/
className?: string;
variant?: MessageVariant;
type: MessageType;
}
export const Message: ReactFCC<MessageProps> = (props) => {
const { children, className, variant, type } = props;
return (
<div className={clsx(s.Message, s[`Message_variant_${variant}`], s[`Message_type_${type}`], className)}>
{children}
</div>
);
};

View File

@ -0,0 +1 @@
export * from './Message';

View File

@ -0,0 +1,19 @@
import React, { ChangeEvent, FunctionComponent } from 'react';
import {Checkbox, ICheckboxProps} from '../Checkbox';
export interface RadioProps extends Omit<ICheckboxProps, 'radio' | 'onChange'> {
/**
* Обработчик изменения значения Radio. Принимает на вход значение Radio и ChangeEvent
*/
onChange?: (value: string, e: ChangeEvent<HTMLInputElement>) => void;
}
export const Radio: FunctionComponent<RadioProps> = ({ onChange, ...props }) => {
const handleChange = onChange
? (value: boolean, event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, event);
}
: undefined;
return <Checkbox {...props} radio={true} onChange={handleChange} />;
};

View File

@ -0,0 +1 @@
export * from './Radio';

View File

@ -0,0 +1,132 @@
@import 'src/app/styles/vars';
$range-track-height: 10px;
$range-thumb-height: 24px;
@mixin range-track {
@include transition(background);
background: $color-on-surface-dark-400;
height: $range-track-height;
border-radius: $range-track-height;
&:hover {
background: $color-on-surface-light-400;
}
}
@mixin range-thumb {
@include transition(background);
width: $range-thumb-height;
height: $range-thumb-height;
margin-top: -($range-thumb-height / 2 - $range-track-height / 2);
border-radius: 50%;
background: $color-brand-primary;
&:hover {
background: $color-brand-hover;
}
}
.Range {
@include flex-col;
gap: $spacing-small-3x;
}
.Range__label {
}
.Range__container {
@include flex-middle;
column-gap: $spacing-small-3x;
}
.Range__inputContainer {
position: relative;
height: $range-thumb-height;
flex: 1;
}
.Range__input {
display: block;
bottom: -7px;
position: relative;
width: 100%;
z-index: 1;
// reset styles
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
// track
&::-webkit-slider-runnable-track {
@include range-track;
}
&::-moz-range-track {
@include range-track;
}
// thumb
&::-webkit-slider-thumb {
-webkit-appearance: none; /* Override default look */
appearance: none;
@include range-thumb;
}
&::-moz-range-thumb {
border: none; /*Removes extra border that FF applies*/
border-radius: 0; /*Removes default border-radius that FF applies*/
@include range-thumb;
}
}
.Range__inputContainer:hover {
.Range__input::-webkit-slider-thumb {
background: $color-brand-hover;
}
.Range__input::-moz-range-thumb {
background: $color-brand-hover;
}
.Range__input::-webkit-slider-runnable-track {
background: $color-on-surface-light-400;
}
.Range__input::-moz-range-track {
background: $color-on-surface-light-400;
}
}
.Range__progress {
@include transition(background);
position: absolute;
background: $color-brand-primary;
height: $range-track-height;
border-radius: $range-track-height 0 0 $range-track-height;
top: 7px;
left: 0;
cursor: pointer;
pointer-events: none;
z-index: 2;
}
.Range__inputContainer:hover .Range__progress {
background: $color-brand-hover;
}
.Range__tip {
@include text-caption-m-regular;
color: $color-text-secondary;
}
.Range__tip_active {
color: $color-text-brand;
min-width: 25px;
}

View File

@ -0,0 +1,55 @@
import clsx from 'clsx';
import s from './Range.module.scss';
import {ReactFCC} from '../../utils/ReactFCC';
import {ChangeEvent, useMemo} from 'react';
import {InputPropsWithoutRef} from '../../utils/types';
import {currencyFormatter} from '../../utils/fomat';
export interface RangeProps extends Omit<InputPropsWithoutRef, 'value' | 'onChange'> {
/**
* Дополнительный css-класс
*/
className?: string;
label?: string;
value: number;
onChange: (value: number, e: any) => void;
format?: boolean;
}
export const Range: ReactFCC<RangeProps> = (props) => {
const {className, min = 0, max = 100, label, value, format, onChange: onChangeProp, ...rest } = props;
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
onChangeProp?.(val, e);
}
const percent = useMemo(() => {
const percent = ((Number(value) - Number(min)) / (Number(max) - Number(min))) * 100;
if (percent < 0) {
return 0;
} else if (percent > 100) {
return 100;
}
return percent;
}, [max, min, value]);
return (
<div className={clsx(s.Range, className)}>
<div className={s.Range__label}>{label}</div>
<div className={s.Range__container}>
<div className={s.Range__tip}>{format ? currencyFormatter.format(min as number) : min}</div>
<div className={s.Range__inputContainer}>
<div className={s.Range__progress} style={{ width: `calc(${percent}% - 1px)` }} />
<input className={s.Range__input} type="range" value={value} onChange={onChange} min={min} max={max} {...rest} />
</div>
<div className={clsx(s.Range__tip, s.Range__tip_active)}>{format ? currencyFormatter.format(value as number) : value}</div>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './Range';

View File

@ -0,0 +1,136 @@
@import 'src/app/styles/vars';
.Input {
@include text-body-m-regular;
@include transition(border-color, box-shadow);
position: relative;
display: flex;
align-items: center;
min-height: 50px;
padding: 15px $spacing-small-x;
color: $color-text-primary;
overflow: hidden;
border: 1px solid transparent;
border-radius: $radius-small;
background-color: $color-on-surface-dark-400;
cursor: text;
box-sizing: border-box;
overflow-y: auto;
word-wrap: break-word;
&:hover {
box-shadow: $shadow-hover;
}
}
.Input_error {
border-color: $color-system-error;
}
.Input_focus {
border-color: $color-brand-primary;
}
.Input_disabled {
color: $color-text-tertiary;
border-color: transparent;
background: $color-on-surface-dark-300;
cursor: default;
&:hover {
box-shadow: none;
}
}
.Input__input {
@include reset-default-input;
@include remove-autofill-style;
position: relative;
flex-grow: 1;
min-width: 0;
height: 100%;
resize: none;
white-space: break-spaces;
&:placeholder-shown + .Input__clear {
display: none;
}
&[type='number'] {
appearance: textfield;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
caret-color: $color-text-primary;
-webkit-text-fill-color: $color-text-primary;
}
}
.Input__input_disabled {
cursor: default;
}
.Input__input::placeholder {
color: $color-text-tertiary;
opacity: 1;
}
.Input__icon {
display: flex;
flex: 0 0 auto;
align-items: center;
max-height: 100%;
color: $color-on-surface-light-300;
}
.Input__icon_active {
@include transition(color);
cursor: pointer;
&:hover {
color: $color-on-surface-light-200;
}
}
.Input__icon_left {
justify-content: flex-start;
margin-right: $spacing-small-3x;
margin-left: -$spacing-small-3x;
}
.Input__icon_right {
justify-content: flex-end;
margin-left: $spacing-small-3x;
&:last-child {
margin-right: -$spacing-small-3x;
}
}
.Input__icon_clear {
@include transition(color);
position: relative;
cursor: pointer;
&:hover {
color: $color-on-surface-light-200;
}
}
.Input_disabled .Input__icon_clear {
pointer-events: none;
}

View File

@ -0,0 +1,113 @@
import React, {useEffect, useRef} from 'react';
import clsx from 'clsx';
import composeRefs from '@seznam/compose-react-refs';
import s from './Textarea.module.scss';
import {IntrinsicPropsWithoutRef} from '../../utils/types';
import {useDelegateFocus} from '../../hooks/useDelegateFocus';
import {useFocus} from '../../hooks/useFocus';
import {UseFormRegisterReturn} from 'react-hook-form';
export interface TextAreaProps extends Omit<IntrinsicPropsWithoutRef<'textarea'>, 'onClick' | 'onBlur'> {
/**
* Состояние ошибки
*/
error?: boolean;
/**
* Проп для контролируемого включения состояния фокуса
*/
focused?: boolean;
/**
* Ref на input-элемент
*/
inputRef?: React.Ref<HTMLInputElement>;
/**
* Обработчик нажатия на Input
*/
onClick?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
/**
* Дополнительные css-классы элементов:
* * root - внешний контейнер
* * input - элемент input
* * icon - иконки слева и справа Input
* * iconLeft - иконка слева Input
* * iconRight - иконка справа Input
*/
classes?: {
root?: string;
icon?: string;
iconLeft?: string;
iconRight?: string;
input?: string;
};
registration?: Partial<UseFormRegisterReturn>;
}
export type InputType = React.ForwardRefExoticComponent<TextAreaProps & React.RefAttributes<HTMLDivElement>>;
const TextareaForwardedRef = React.forwardRef<HTMLDivElement, TextAreaProps>(
(
{
error,
focused: focusedProp,
className,
classes,
onClick,
onInput,
inputRef: inputRefProp,
style,
registration,
...inputProps
},
ref
) => {
const inputRef = useRef<HTMLInputElement>(null);
const delegateProps = useDelegateFocus<HTMLDivElement, HTMLInputElement>(inputRef, { onClick });
const { focused, ...focusProps } = useFocus({ ...inputProps, ...registration });
if (inputRef.current) {
inputRef.current.style.height = '1px';
inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
}
return (
<div
ref={ref}
className={clsx(
s.Input,
{
[s.Input_focus]: focusedProp ?? focused,
[s.Input_error]: error,
[s.Input_disabled]: inputProps.disabled
},
className,
classes?.root
)}
style={style}
{...delegateProps}>
<textarea
className={clsx(s.Input__input, { [s.Input__input_disabled]: inputProps.disabled }, classes?.input)}
autoComplete={'off'}
{...registration}
// @ts-ignore
ref={composeRefs(inputRef, inputRefProp, registration?.ref)}
{...inputProps}
{...focusProps}
// @ts-ignore
onInput={(e) => {
setTimeout(() => {
if (inputRef.current) {
inputRef.current.style.height = '1px';
inputRef.current.style.height = inputRef.current.scrollHeight + 'px';
}
});
}}
/>
</div>
);
}
);
TextareaForwardedRef.displayName = 'Input';
export const Textarea: InputType = TextareaForwardedRef;

View File

@ -0,0 +1 @@
export * from './Textarea';

2
src/config.ts Normal file
View File

@ -0,0 +1,2 @@
export const API_URL = 'http://192.168.83.181:8000/api';
// export const API_URL = 'http://192.168.22.4:8000/api';

View File

@ -0,0 +1,32 @@
import React, { useCallback } from 'react';
/**
* Прокидывает фокус на targetRef при взаимодействии с элементом
* @param targetRef - реф элемента, куда прокидывать клик
* @param props
* @param props.onClick - внешний обработчик клика
* @returns
* - onClick - новый обработчик клика для элемента
*/
export const useDelegateFocus = <Root extends HTMLElement, Target extends HTMLElement>(
targetRef: React.MutableRefObject<Target | null>,
{
onClick: onClickProp
}: {
onClick?: React.MouseEventHandler<Root>;
} = {}
) => {
const onClick = useCallback(
(event: React.MouseEvent<Root, MouseEvent>) => {
if (targetRef.current && event.target !== targetRef.current) {
targetRef.current.focus();
}
onClickProp?.(event);
},
[onClickProp, targetRef]
);
return {
onClick
};
};

View File

@ -0,0 +1,13 @@
import { useRef } from 'react';
/**
* Единожды вызывает функцию и сохраняет ее значение в реф
* @param factory - функция, создающая значение рефа
*/
export function useFactoryRef<T>(factory: () => T): T {
const ref = useRef<T | null>(null);
if (!ref.current) {
ref.current = factory();
}
return ref.current;
}

55
src/hooks/useFocus.ts Normal file
View File

@ -0,0 +1,55 @@
import { FocusEvent, FocusEventHandler, useState } from 'react';
import { useFactoryRef } from './useFactoryRef';
import { usePropsRef } from './usePropsRef';
export interface IUseFocusProps<Element> {
/**
* Внешний обработчик onFocus
*/
onFocus?: FocusEventHandler<Element>;
/**
* Внешний обработчик onBlur
*/
onBlur?: FocusEventHandler<Element>;
}
/**
* Позволяет использовать focused как состояние компонента.
* Активируется, когда элемент или его потомки становятся focus-visible
*
* @returns
* - focused - состояние фокуса (true если :focus-visible)
* - onFocus, onBlur - обработчики, которые надо повесить на элемент
*/
export function useFocus<Element extends HTMLElement>({ onFocus, onBlur }: IUseFocusProps<Element> = {}) {
const [focused, setFocused] = useState(false);
/**
* Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
* при смене onFocus/onBlur в пропах хука
*/
const propsRef = usePropsRef<IUseFocusProps<Element>>({
onFocus,
onBlur
});
const funcsRef = useFactoryRef(() => ({
onFocus: (e: FocusEvent<Element>) => {
if (e.target.classList.contains('focus-visible')) {
setFocused(true);
}
propsRef.onFocus?.(e);
},
onBlur: (e: FocusEvent<Element>) => {
setFocused(false);
propsRef.onBlur?.(e);
}
}));
return {
focused,
onFocus: funcsRef.onFocus,
onBlur: funcsRef.onBlur
};
}

52
src/hooks/useHover.ts Normal file
View File

@ -0,0 +1,52 @@
import { MouseEvent, MouseEventHandler, useState } from 'react';
import { useFactoryRef } from './useFactoryRef';
import { usePropsRef } from './usePropsRef';
export interface IUseHoverProps<Element> {
/**
* Внешний обработчик onMouseOver
*/
onMouseOver?: MouseEventHandler<Element>;
/**
* Внешний обработчик onMouseOut
*/
onMouseOut?: MouseEventHandler<Element>;
}
/**
* Позволяет использовать hovered как состояние компонента.
*
* @returns
* - hovered - состояние фокуса
* - onMouseOver, onMouseOut - обработчики, которые надо повесить на элемент
*/
export function useHover<Element extends HTMLElement>({ onMouseOver, onMouseOut }: IUseHoverProps<Element> = {}) {
const [hovered, setHovered] = useState(false);
/**
* Используем useRef вместо useCallback, чтобы прервать каскад ререндеров
* при смене onMouseOver/onMouseOut в пропах хука
*/
const propsRef = usePropsRef<IUseHoverProps<Element>>({
onMouseOver,
onMouseOut
});
const funcsRef = useFactoryRef(() => ({
onMouseOver: (e: MouseEvent<Element>) => {
setHovered(true);
propsRef.onMouseOver?.(e);
},
onMouseOut: (e: MouseEvent<Element>) => {
setHovered(false);
propsRef.onMouseOut?.(e);
}
}));
return {
hovered,
onMouseOver: funcsRef.onMouseOver,
onMouseOut: funcsRef.onMouseOut
};
}

View File

@ -0,0 +1,3 @@
import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
export const useIsDesktop = () => useMediaQueryResult(mediaQuery.tabletSmallDown);

3
src/hooks/useIsMobile.ts Normal file
View File

@ -0,0 +1,3 @@
import {mediaQuery, useMediaQueryResult} from './useMediaQueryResult';
export const useIsMobile = () => useMediaQueryResult(mediaQuery.tabletSmallDown);

View File

@ -0,0 +1,19 @@
import { useRef } from "react";
import { useFactoryRef } from './useFactoryRef';
/**
* Позволяет обернуть меняющийся колбек в неменяющйеся с помощью рефа
* @param callback - колбек
* @returns постоянная функция, которая вызывает последний переданный колбек
* @example
* const t = new Date();
* const onClick = useLatestCallbackRef(() => console.log(t));
* onClick -> постоянная функция, которая вызывает последний переданный колбек
*/
export function useLatestCallbackRef<Args extends any[], Result>(
callback: (...args: Args) => Result
): (...args: Args) => Result {
const callbackRef = useRef<(...args: Args) => Result>(callback);
callbackRef.current = callback;
return useFactoryRef(() => (...args: Args) => callbackRef.current(...args));
}

67
src/hooks/useLiveInput.ts Normal file
View File

@ -0,0 +1,67 @@
import { useCallback, useState } from "react";
import { useTimeoutRef } from "./useTimeoutRef";
import { useLatestCallbackRef } from "./useLatestCallbackRef";
/**
* Дебаунсит событие изменения, сохраняя в стейте флаг активности дебауса.
* Например, при использовании с формой поиска, когда пользователь нажимает
* какую-либо клавишу, стейт isNewInput становится true, но колбек onSubmit
* не вызывается. В этот момент можно показать пользователю индикатор загрузки,
* сделав вид, что запрос отправился, хотя на самом деле запрос отправится
* только после того, как пользователь не будет ничего нажимать в течение
* времени delay.
*
* @param onSubmit - колбек, который вызывается с дебаунсом
* @param delay - время дебаунса
* @example
* // Какой-то хук загрузки данных
* const [data, isLoading, loadData] = useData();
*
* const [search, setSearch] = useState('');
* const [isNewInput, submit] = useLiveInput((value) => {
* loadData(value);
* });
* const handleChange = (e) => {
* setSearch(search);
* // При вводе в инпут инициируем запрос с дебаунсом
* submit(e.target.value, true);
* };
* const handleSubmit = () => {
* // При отправке формы отправляем запрос сразу
* submit(search, false);
* };
*
* return <div>
* <form onSubmit={handleSubmit}>
* <input onChange={handleChange} name="search" value={search} />
* <button>Search</button>
* </form>
* <div>
* {(isNewInput || isLoading) ? <Spinner /> : <Data data={data} />}
* </div>
* </div>;
*/
export function useLiveInput<Value>(onSubmit: (value: Value) => void, delay: number = 500) {
const [isNewInput, setIsNewInput] = useState(false);
const submitTimeoutRef = useTimeoutRef();
const setFiltersRef = useLatestCallbackRef((value: Value) => {
setIsNewInput(false);
onSubmit(value);
});
const handleSubmit = useCallback(
(value: Value, useTimeout: boolean = false) => {
if (useTimeout) {
setIsNewInput(true);
submitTimeoutRef.set(() => {
setFiltersRef(value);
}, delay);
} else {
submitTimeoutRef.clear();
setFiltersRef(value);
}
},
[submitTimeoutRef, setFiltersRef, delay],
);
return [isNewInput, handleSubmit] as const;
}

View File

@ -0,0 +1,39 @@
import { useMemo, useState, useEffect } from 'react';
export const breakpointMobileSmall = 320;
export const breakpointMobileLarge = 375;
export const breakpointTabletSmall = 768;
export const breakpointTabletLarge = 1024;
export const breakpointDesktopSmall = 1280;
export const breakpointDesktopMedium = 1440;
export const breakpointDesktopLarge = 1920;
export const mediaQuery = {
mobileLargeDown: `(max-width: ${breakpointMobileLarge - 1}px)`, // 374
tabletSmallDown: `(max-width: ${breakpointTabletSmall - 1}px)`, // 767
tabletSmallUp: `(min-width: ${breakpointTabletSmall}px)`, // 767
tabletLargeDown: `(max-width: ${breakpointTabletLarge - 1}px)`, // 1023
tabletLargeUp: `(min-width: ${breakpointTabletLarge}px)`, // 1024
desktopSmallDown: `(max-width: ${breakpointDesktopSmall - 1}px)`, // 1279
desktopSmallUp: `(min-width: ${breakpointDesktopSmall}px)`, // 1280
desktopMediumDown: `(max-width: ${breakpointDesktopMedium - 1}px)`, // 1439
desktopMediumUp: `(min-width: ${breakpointDesktopMedium}px)`, // 1440
desktopLarge: `(min-width: ${breakpointDesktopLarge}px)` // 1919
};
export function useMediaQueryResult(mediaQueryString: string) {
const mediaQueryList = useMemo(() => {
return window.matchMedia(mediaQueryString);
}, [mediaQueryString]);
const [queryResult, setQueryResult] = useState(mediaQueryList.matches);
useEffect(() => {
const handleMediaQueryListChange = (e: MediaQueryListEvent) => setQueryResult(e.matches);
mediaQueryList.addEventListener('change', handleMediaQueryListChange);
return () => mediaQueryList.removeEventListener('change', handleMediaQueryListChange);
}, [mediaQueryList]);
return queryResult;
}

15
src/hooks/usePrevious.ts Normal file
View File

@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react';
export const usePrevious = <T>(value: T, overwriteWhenUndefined = true): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
if (!overwriteWhenUndefined && value === undefined) {
return;
}
ref.current = value;
}, [value, overwriteWhenUndefined]);
return ref.current;
};

7
src/hooks/usePropsRef.ts Normal file
View File

@ -0,0 +1,7 @@
import { useRef } from 'react';
export function usePropsRef<T extends object>(props: T) {
const ref = useRef<T>(props);
Object.assign(ref.current, props);
return ref.current;
}

View File

@ -0,0 +1,6 @@
import { SingleTimeoutManager } from '../utils/SingleTimeoutManager';
import { useFactoryRef } from './useFactoryRef';
export const useSingleTimeout = () => {
return useFactoryRef(() => new SingleTimeoutManager());
};

View File

@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import {SingleTimeoutManager} from '../utils/SingleTimeoutManager';
/**
* Создает таймаут менеджер, который автоматически очищает активный
* таймаут при анмаунте компонента. Активен может быть только один
* таймаут. При создании нового предыдущий отключается.
*
* @example
* function MyComponent() {
* const [loading, setLoading] = useState(false);
* const timeoutRef = useTimeoutRef();
* const handleClick = () => {
* setLoading(true);
* timeoutRef.set(() => {
* setLoading(false);
* }, 2000);
* };
*
* return <button type="button" onClick={handleClick}>
* {loading ? 'Loading' : 'Click'}
* </button>;
* }
*/
export function useTimeoutRef() {
const ref = useRef<SingleTimeoutManager | null>(null);
if (!ref.current) {
ref.current = new SingleTimeoutManager();
}
useEffect(() => {
return () => {
if (ref.current) ref.current.clear();
};
}, []);
return ref.current;
}

37
src/hooks/useToggle.ts Normal file
View File

@ -0,0 +1,37 @@
import { useCallback, useState } from 'react';
type TUseToggleReturn = [
value: boolean,
funcs: {
set: () => void;
unset: () => void;
toggle: () => void;
change: (value: boolean) => void;
}
];
export const useToggle = (defaultValue: boolean = false): TUseToggleReturn => {
const [value, change] = useState(defaultValue);
const set = useCallback(() => {
change(true);
}, [change]);
const unset = useCallback(() => {
change(false);
}, [change]);
const toggle = useCallback(() => {
change((v) => !v);
}, [change]);
return [
value,
{
change,
set,
unset,
toggle
}
];
};

24
src/hooks/useUrlParam.ts Normal file
View File

@ -0,0 +1,24 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
export interface UseUrlParamOptions<T> {
parser?: (value: string) => T;
}
export const useUrlParam = <T = string | null>(key: string, options: UseUrlParamOptions<T> = {}) => {
const { [key]: param } = useParams();
const value = useMemo(() => {
if (param === undefined) {
return null;
}
if (options.parser) {
return options.parser(param);
}
return param;
}, [param, options]);
return value as T | null;
};

View File

@ -1,13 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -1,16 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {App} from './app';
import {AppProvider} from './app';
import 'focus-visible';
import 'sanitize.css';
import 'sanitize.css/forms.css';
import 'sanitize.css/typography.css';
import './app/styles/index.scss';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
// <React.StrictMode>
<AppProvider>
<App />
</AppProvider>
// </React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function

10
src/lib/axios.ts Normal file
View File

@ -0,0 +1,10 @@
import Axios from 'axios';
import {API_URL} from '../config';
export const axios = Axios.create({
baseURL: API_URL
});
axios.interceptors.response.use((response) => {
return response.data;
})

38
src/lib/react-query.ts Normal file
View File

@ -0,0 +1,38 @@
import {
QueryClient,
UseQueryOptions,
UseMutationOptions,
DefaultOptions,
UseInfiniteQueryOptions
} from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { ExtractFnReturnType } from '../utils/types';
const queryConfig: DefaultOptions = {
queries: {
useErrorBoundary: false,
refetchOnWindowFocus: false,
retry: false
}
};
export const queryClient = new QueryClient({ defaultOptions: queryConfig });
export type QueryConfig<QueryFnType extends (...args: any) => any> = Omit<
UseQueryOptions<ExtractFnReturnType<QueryFnType>>,
'queryKey' | 'queryFn'
>;
export type InfiniteQueryConfig<QueryFnType extends (...args: any) => any> = Omit<
UseInfiniteQueryOptions<ExtractFnReturnType<QueryFnType>>,
'queryKey' | 'queryFn'
>;
export type MutationConfig<MutationFnType extends (...args: any) => any> = UseMutationOptions<
ExtractFnReturnType<MutationFnType>,
AxiosError,
Parameters<MutationFnType>[0],
any
>;
export type { ExtractFnReturnType };

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1,86 @@
@import 'src/app/styles/vars';
// Общий лейаут
.ChatPage {
}
.ChatPage__inner {
position: relative;
//width: $breakpoint-tablet-small;
width: 100%;
height: 100vh;
max-height: 100svh;
margin: 0 auto;
overflow: hidden;
background-image: url('./assets/background2.jpg');
background-size: 150%;
@include media-down(tablet-small) {
width: 100%;
}
}
.ChatPage__container {
height: 100%;
position: absolute;
width: 100%;
z-index: 1;
//background-color: rgba($color-on-surface-dark-100, 0.6);
//background-color: $color-surface-primary;
background-color: rgba($color-surface-primary, 0.7);
backdrop-filter: blur(10px);
padding: $spacing-medium-x $spacing-small 0;
overflow-y: scroll;
@include scrollbar;
@include flex-col;
}
// Инпут
.ChatPage__inputContainer {
position: absolute;
width: 100%;
min-height: 100px;
padding: $spacing-small;
bottom: 0;
background-color: rgba($color-surface-primary, 0.6);
backdrop-filter: blur(10px);
z-index: 2;
@include flex-col;
}
.ChatPage__skipButton {
width: 100% !important;
margin-top: $spacing-small;
}
// Секция с контентом и сообщениями
.ChatPage__content {
flex: 1;
display: flex;
flex-direction: column;
//justify-content: flex-end;
row-gap: $spacing-small;
}
.ChatPage__message {
width: fit-content;
max-width: 66%;
@include media-down(tablet-small) {
max-width: 100%;
}
}
.ChatPage__message_right {
margin-left: auto;
}
.ChatPage__message_left {
margin-right: auto;
}

188
src/pages/chat/ChatPage.tsx Normal file
View File

@ -0,0 +1,188 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import clsx from 'clsx';
import s from './ChatPage.module.scss';
import {ReactFCC} from '../../utils/ReactFCC';
import {ChatContent} from './components/ChatContent';
import {ChatItemType, useChatHistory} from './store/history';
import {SubmitHandler} from 'react-hook-form';
import {getFirstQuestion, useCreateDeck} from '../../api/deck';
import {ChatFormInitial} from './components/ChatForm/ChatFormInittial';
import {useChatUi} from './hooks/useChatUi';
import {useQuestion} from '../../api/deck/getQuestion';
import {useCreateAnswer} from '../../api/deck/createAnswer';
import {QuestionFactory} from './components/ChatForm/QuestionFactory';
import {useSingleTimeout} from '../../hooks/useSingleTimeout';
import {usePrevious} from '../../hooks/usePrevious';
import {generateAnswerFromData} from './utils/generateAnswerFromData';
import {generateTextFromAnswer} from './utils/generateTextFromAnswer';
import {Button, ButtonVariant} from '../../components/Button';
export interface ChatPageProps {
/**
* Дополнительный css-класс
*/
className?: string;
}
const QUESTION_POLLING_MS = 1000;
const DEFAULT_DECK_ID = 0;
const DEFAULT_QUESTION_ID = 0;
export const ChatPage: ReactFCC<ChatPageProps> = (props) => {
const {className} = props;
const timeout = useSingleTimeout();
// Работа с UI
const { backgroundRef, containerRef, contentRef, inputContainerRef } = useChatUi();
const { history, pushHistory } = useChatHistory();
const initRef = useRef(false);
// Устанавливаем первый вопрос в чат
useEffect(() => {
if (!initRef.current) {
pushHistory({
type: ChatItemType.receive,
text: 'Введите описание проекта',
});
initRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Работа с API
*/
const [deckId, setDeckId] = useState(DEFAULT_DECK_ID);
const [questionId, setQuestionId] = useState(DEFAULT_QUESTION_ID);
const { mutateAsync: createDeck } = useCreateDeck();
const onSubmitInitial: SubmitHandler<any> = useCallback(async (data) => {
const deck = await createDeck({
description: data.description
});
setDeckId(deck.id);
pushHistory({
type: ChatItemType.send,
text: deck.description as string
});
const firstQuestion = await getFirstQuestion({ deckId });
setQuestionId(firstQuestion.id);
}, [createDeck, deckId, pushHistory]);
// Начинаем пинг-понг вопросов-ответов
const { data: question, refetch: refetchQuestion } = useQuestion({
deckId,
questionId,
config: {
enabled: !!(deckId && questionId),
// keepPreviousData: true,
}
});
const prevQuestion = usePrevious(question);
useEffect(() => {
if (question && question.id !== prevQuestion?.id) {
timeout.clear();
pushHistory({
type: ChatItemType.receive,
text: question.text
});
const startPolling = () => {
timeout.set(async () => {
const { data: newQuestion } = await refetchQuestion();
if (newQuestion?.hint && !newQuestion.hint.type) {
startPolling();
}
}, QUESTION_POLLING_MS);
}
if (question?.hint && !question.hint.type) {
startPolling();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pushHistory, question]);
const { mutateAsync: createAnswer } = useCreateAnswer();
const onSubmit: SubmitHandler<any> = useCallback(async (data) => {
if (!question || !data.value) {
return;
}
timeout.clear();
const answerValue = generateAnswerFromData(question, data);
const answer = await createAnswer({
deckId,
questionId,
answer: answerValue
});
pushHistory({
type: ChatItemType.send,
text: generateTextFromAnswer(question.type, answer)
});
setQuestionId(question!.next_id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createAnswer, deckId, pushHistory, question, questionId]);
// Пропуск вопроса
const onSkip = useCallback(() => {
if (question && !question.params?.required) {
setQuestionId(question.next_id);
}
}, [question]);
// ---------- Скролл чата ----------
// todo при печатании текста тоже двигать скролл
useEffect(() => {
if (contentRef.current && inputContainerRef.current) {
contentRef.current.style.paddingBottom = inputContainerRef.current.scrollHeight + 'px';
containerRef.current?.scrollTo({ top: contentRef.current.scrollHeight });
}
}, [containerRef, contentRef, history, question, inputContainerRef]);
return (
<div className={clsx(s.ChatPage, className)}>
<div className={s.ChatPage__inner} ref={backgroundRef}>
<div className={s.ChatPage__container} ref={containerRef}>
<div className={s.ChatPage__content} ref={contentRef}>
<ChatContent history={history} />
</div>
</div>
<div className={s.ChatPage__inputContainer} ref={inputContainerRef}>
{question ? (
<>
<QuestionFactory onSubmit={onSubmit} {...question} />
{!question.params?.required && (
<Button
className={s.ChatPage__skipButton}
variant={ButtonVariant.secondary}
onClick={() => onSkip()}
>Пропустить</Button>
)}
</>
) : !deckId ? (
<ChatFormInitial onSubmit={onSubmitInitial} />
) : null}
</div>
</div>
</div>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -0,0 +1,86 @@
import clsx from 'clsx';
import {ReactFCC} from '../../../utils/ReactFCC';
import s from '../ChatPage.module.scss';
import {Message, MessageType, MessageVariant} from '../../../components/Message';
import {Fragment, memo} from 'react';
import {mediaQuery, useMediaQueryResult} from '../../../hooks/useMediaQueryResult';
import {ChatItem, ChatItemType} from '../store/history';
export interface ChatMockProps {
/**
* Дополнительный css-класс
*/
className?: string;
history: ChatItem[];
}
export const ChatContent: ReactFCC<ChatMockProps> = memo(function ChatMock(props) {
const { history } = props;
const isLarge = useMediaQueryResult(mediaQuery.desktopMediumUp);
const messageTypeForSend = isLarge ? MessageType.left : MessageType.right;
const messageTypeClassForSend = isLarge ? s.ChatPage__message_left : s.ChatPage__message_right;
return (
<>
{history.map((item, index) => (
<Message
className={clsx(s.ChatPage__message, {
[messageTypeClassForSend]: item.type === ChatItemType.send,
[s.ChatPage__message_left]: item.type === ChatItemType.receive,
})}
type={item.type === ChatItemType.send ? messageTypeForSend : MessageType.left}
variant={item.type === ChatItemType.send ? MessageVariant.primary : MessageVariant.secondary}
key={index}>
{item.text}
</Message>
))}
{/*{Array(5).fill(null).map((_, index) => (*/}
{/* <Fragment key={index}>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
{/* type={MessageType.left}*/}
{/* variant={MessageVariant.secondary}>*/}
{/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
{/* </Message>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
{/* type={messageTypeForSend}*/}
{/* variant={MessageVariant.primary}>*/}
{/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
{/* </Message>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
{/* type={MessageType.left}*/}
{/* variant={MessageVariant.secondary}>*/}
{/* На чем вы зарабатываете? Сколько и за что вам платят клиенты*/}
{/* </Message>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
{/* type={messageTypeForSend}*/}
{/* variant={MessageVariant.primary}>*/}
{/* Проект может зарабатывать на платной подписке*/}
{/* </Message>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, s.ChatPage__message_left)}*/}
{/* type={MessageType.left}*/}
{/* variant={MessageVariant.secondary}>*/}
{/* Какие метрики вы используете чтобы отслеживать прогресс развития проекта?*/}
{/* </Message>*/}
{/* <Message*/}
{/* className={clsx(s.ChatPage__message, messageTypeClassForSend)}*/}
{/* type={messageTypeForSend}*/}
{/* variant={MessageVariant.primary}>*/}
{/* Возможными метриками для отслеживания прогресса могут быть: количество скачиваний и использования приложения/сервиса, количество активных пользователей, уровень удовлетворенности пользователей, объем продаж/дохода, показатели роста/расширения компании и др.*/}
{/* </Message>*/}
{/* </Fragment>*/}
{/*))}*/}
</>
);
});

View File

@ -0,0 +1,54 @@
import {ReactFCC} from '../../../../utils/ReactFCC';
import s from './components/ChatFormText/ChatFormText.module.scss';
import {Textarea} from '../../../../components/Textarea';
import {KeyboardEvent} from 'react';
import {isKey} from '../../../../utils/isKey';
import {Key} from 'ts-key-enum';
import {Button} from '../../../../components/Button';
import {Form} from '../../../../components/Form';
import {SubmitHandler} from 'react-hook-form';
import {ReactComponent as RightIcon} from '../../../../assets/icons/right.svg';
export interface ChatFormInitialProps {
/**
* Дополнительный css-класс
*/
className?: string;
onSubmit: SubmitHandler<any>;
}
export const ChatFormInitial: ReactFCC<ChatFormInitialProps> = (props) => {
const {onSubmit} = props;
return (
<Form>
{({ register, handleSubmit }) => {
return (
<div className={s.ChatFormText__richInput}>
<Textarea
className={s.ChatFormText__input}
placeholder={'Введите сообщение'}
rows={1}
cols={33}
onKeyDown={(e: KeyboardEvent) => {
if (isKey(e.nativeEvent, Key.Enter)) {
e.preventDefault();
handleSubmit(onSubmit)(e);
}
}}
registration={register('description', {
required: true,
max: 1000,
})}
/>
<Button className={s.ChatFormText__richInputButton} onClick={handleSubmit(onSubmit)}>
<RightIcon className={s.ChatFormText__buttonIcon} />
</Button>
</div>
)
}}
</Form>
);
};

Some files were not shown because too many files have changed in this diff Show More