feat(response): add response page layout

This commit is contained in:
Pavel Torbeev 2023-09-09 10:07:47 +03:00
parent dbfd8e8b4f
commit b2285e263b
27 changed files with 624 additions and 6 deletions

View File

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

View File

@ -3,7 +3,8 @@ import { ChatPage } from '../../pages/chat';
import { HomePage } from '../../pages/home'; import { HomePage } from '../../pages/home';
import { DefaultLayout } from '../../pages/_layouts/DefaultLayout'; import { DefaultLayout } from '../../pages/_layouts/DefaultLayout';
import { TextPage } from '../../pages/text'; import { TextPage } from '../../pages/text';
import { CHAT_PAGE_ROUTE, HOME_PAGE_ROUTE, TEXT_PAGE_ROUTE } from './routes'; import { ResponsePage } from '../../pages/response';
import { CHAT_PAGE_ROUTE, HOME_PAGE_ROUTE, RESPONSE_PAGE_ROUTE, TEXT_PAGE_ROUTE } from './routes';
export const AppRoutes = () => { export const AppRoutes = () => {
return ( return (
@ -11,6 +12,7 @@ export const AppRoutes = () => {
<Route element={<DefaultLayout />}> <Route element={<DefaultLayout />}>
<Route path={CHAT_PAGE_ROUTE} element={<ChatPage />} /> <Route path={CHAT_PAGE_ROUTE} element={<ChatPage />} />
<Route path={HOME_PAGE_ROUTE} element={<HomePage />} /> <Route path={HOME_PAGE_ROUTE} element={<HomePage />} />
<Route path={RESPONSE_PAGE_ROUTE} element={<ResponsePage />} />
<Route path={TEXT_PAGE_ROUTE} element={<TextPage />} /> <Route path={TEXT_PAGE_ROUTE} element={<TextPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -1,5 +1,8 @@
export const CHAT_PAGE_ROUTE = `/chat`; export const CHAT_PAGE_ROUTE = `/chat`;
export const HOME_PAGE_ROUTE = `/`; export const HOME_PAGE_ROUTE = `/`;
export const RESPONSE_PAGE_PARAM = 'processId';
export const RESPONSE_PAGE_ROUTE = `/response/:${RESPONSE_PAGE_PARAM}`;
export const TEXT_PAGE_PARAM = 'textId'; export const TEXT_PAGE_PARAM = 'textId';
export const TEXT_PAGE_ROUTE = `/text/:${TEXT_PAGE_PARAM}`; export const TEXT_PAGE_ROUTE = `/text/:${TEXT_PAGE_PARAM}`;

View File

@ -103,7 +103,7 @@ $line-height-67: 67px;
@mixin text-body-s-regular { @mixin text-body-s-regular {
font-family: $font-family-inter, $font-family-default; font-family: $font-family-inter, $font-family-default;
font-size: $font-size-12; font-size: $font-size-14;
font-weight: $font-weight-400; font-weight: $font-weight-400;
line-height: $line-height-18; line-height: $line-height-18;
} }

View File

@ -34,3 +34,7 @@ a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
} }
.scroll-prevented {
overflow: hidden;
}

View File

@ -0,0 +1,67 @@
@import 'src/app/styles/vars';
.Modal {
position: fixed;
z-index: $z-index-modal;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
color: $color-text-primary;
}
.Modal__overlay {
@include transition(opacity);
position: fixed;
z-index: $z-index-overlay;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background: #000;
}
.Modal__content {
@include transition(opacity);
z-index: $z-index-modal;
border-radius: $radius-large;
background: var(--bg-color);
opacity: 0;
@include media-down(tablet-small) {
width: 100%;
margin-top: auto;
}
}
.Modal_entering,
.Modal_entered {
.Modal__overlay {
opacity: 0.5;
}
.Modal__content {
opacity: 1;
}
}
.Modal_exiting,
.Modal_exited {
pointer-events: none;
.Modal__overlay {
opacity: 0;
}
.Modal__content {
opacity: 0;
}
}

View File

@ -0,0 +1,35 @@
import { ReactNode, useRef } from 'react';
import { Transition } from 'react-transition-group';
import clsx from 'clsx';
import { Portal } from '../Portal';
import { usePreventWindowScroll } from '../../hooks/usePreventWindowScroll';
import s from './Modal.module.scss';
export interface ModalProps {
className?: string;
children?: ReactNode;
isOpen?: boolean;
onClose?: () => void;
isClosing?: boolean;
preventWindowScroll?: false;
}
export const Modal = (props: ModalProps) => {
const { className, children, isOpen, onClose, isClosing, preventWindowScroll } = props;
const nodeRef = useRef(null);
usePreventWindowScroll(preventWindowScroll ?? isOpen);
return (
<Portal>
<Transition unmountOnExit nodeRef={nodeRef} timeout={200} in={isOpen && !isClosing}>
{(state) => (
<div ref={nodeRef} className={clsx(s.Modal, s[`Modal_${state}`], className)}>
<div onClick={onClose} className={s.Modal__overlay} />
<div className={s.Modal__content}>{isOpen && children}</div>
</div>
)}
</Transition>
</Portal>
);
};

View File

@ -0,0 +1,11 @@
@import 'src/app/styles/vars';
.ModalBody {
@include scrollbar;
overflow-y: auto;
@include media-down(tablet-small) {
border-radius: 0 0 $radius-small $radius-small;
}
}

View File

@ -0,0 +1,15 @@
import { memo } from 'react';
import clsx from 'clsx';
import { ReactFCC } from '../../../utils/ReactFCC';
import s from './ModalBody.module.scss';
export interface ModalBodyProps {
className?: string;
}
export const ModalBody: ReactFCC<ModalBodyProps> = memo((props) => {
const { children, className } = props;
return <div className={clsx(s.ModalBody, className)}>{children}</div>;
});
ModalBody.displayName = 'ModalBody';

View File

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

View File

@ -0,0 +1,32 @@
@import 'src/app/styles/vars';
.ModalContainer {
@include media-down(tablet-small) {
padding: $spacing-small $spacing-small-x;
}
}
.ModalContainer__wrapper {
display: flex;
width: 600px;
max-height: calc(100vh - $spacing-medium-x * 2);
background: $color-background-primary;
padding: $spacing-small;
&_borderRadius {
border-radius: $radius-medium;
}
@include media-down(tablet-small) {
width: 100%;
max-height: calc(100vh - $spacing-small * 2);
margin-top: auto;
background: $color-background-primary;
}
}
.ModalContainer__content {
display: flex;
flex-direction: column;
width: 100%;
}

View File

@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Modal } from '../Modal';
import s from './ModalContainer.module.scss';
export interface ModalContainerProps {
className?: string;
children?: ReactNode;
onClose?: () => void;
isOpen: boolean;
isClosing?: boolean;
withBorderRadius?: boolean;
preventWindowScroll?: false;
}
export const ModalContainer = (props: ModalContainerProps) => {
const { isClosing, className, children, isOpen, onClose, withBorderRadius = true, preventWindowScroll } = props;
return (
<Modal
className={clsx(s.ModalContainer, {})}
onClose={onClose}
isClosing={isClosing}
isOpen={isOpen}
preventWindowScroll={preventWindowScroll}>
<div
className={clsx(
s.ModalContainer__wrapper,
{ [s.ModalContainer__wrapper_borderRadius]: withBorderRadius },
className
)}>
<div className={s.ModalContainer__content}>{children}</div>
</div>
</Modal>
);
};

View File

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

View File

@ -0,0 +1,5 @@
export * from './Modal';
export * from './ModalContainer';
export * from './ModalBody';
export * from './useModal';
// export * from './ModalAlert';

View File

@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export interface UseModalProps {
onClose?: () => void;
isOpen?: boolean;
canClose?: boolean;
}
const ANIMATION_DURATION = 200;
export function useModal(props: UseModalProps) {
const { isOpen, canClose: canCloseProp = true, onClose } = props;
const [isClosing, setIsClosing] = useState(false);
const [modalIsVisible, setModalIsVisible] = useState(false);
const [canClose, setCanClose] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const close = useCallback(() => {
if (canClose && canCloseProp && onClose) {
onClose();
}
}, [canClose, canCloseProp, onClose]);
const onKeyDown = useCallback(
(evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
close();
}
},
[close]
);
useEffect(() => {
if (isOpen) {
setModalIsVisible(true);
timerRef.current = setTimeout(() => {
setCanClose(true);
window.addEventListener('keydown', onKeyDown);
}, ANIMATION_DURATION);
return () => {
window.removeEventListener('keydown', onKeyDown);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}
if (!isOpen) {
setIsClosing(true);
setCanClose(false);
timerRef.current = setTimeout(() => {
setModalIsVisible(false);
setIsClosing(false);
}, ANIMATION_DURATION);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}
}, [isOpen, onKeyDown]);
return {
isClosing,
close,
modalIsVisible,
ANIMATION_DURATION
};
}

View File

@ -0,0 +1,16 @@
import { createPortal } from 'react-dom';
import { ReactFCC } from '../../utils/ReactFCC';
interface PortalProps {
element?: Element | DocumentFragment | null;
}
export const Portal: ReactFCC<PortalProps> = (props) => {
const { children, element = document.body } = props;
if (!element) {
return <>{children}</>;
}
return createPortal(children, element);
};

View File

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

View File

@ -0,0 +1,13 @@
import { useCallback, useEffect } from 'react';
export const usePreventWindowScroll = (isPrevented?: boolean) => {
const preventScroll = useCallback((isPrevented: boolean) => {
document.body.classList.toggle('scroll-prevented', isPrevented);
}, []);
useEffect(() => {
preventScroll(!!isPrevented);
}, [isPrevented, preventScroll]);
return preventScroll;
};

View File

@ -0,0 +1,78 @@
@import 'src/app/styles/vars';
.ResponsePage {
}
.ResponsePage__title {
margin-bottom: $spacing-medium;
}
.ResponsePage__container {
width: 100%;
@include mobile-down {
@include flex-col;
row-gap: $spacing-small-2x;
}
}
.ResponsePage__table {
width: 100%;
border-collapse: collapse;
border-radius: $radius-small;
border-style: hidden;
box-shadow: 0 0 0 1px $color-border-default;
tbody tr {
@include transition(background-color);
cursor: pointer;
&:hover {
background-color: $color-background-secondary;
td {
border-color: $color-border-hover;
&, > span {
color: $color-text-brand !important;
}
}
.ResponsePage__tableSummary {
//opacity: 1;
}
}
&:active {
background-color: $color-button-secondary-hover-fill;
}
}
th, td {
padding: $spacing-small-3x $spacing-small-2x;
border: 1px solid $color-border-default;
}
th {
text-align: left;
font-weight: $font-weight-medium;
}
td {
@include transition(border-color, color);
&.ResponsePage__tableSummary {
@include transition(opacity);
opacity: 0;
&:hover {
opacity: 1;
}
}
> span {
@include transition(color);
}
}
}

View File

@ -0,0 +1,108 @@
import { FC, useCallback, useState } from 'react';
import { Heading, HeadingSize } from '../../components/Heading';
import { Link } from '../../components/Link';
import { getPercentageColor } from '../../utils/getPercentageColor';
import { EMDASH } from '../../utils/chars';
import { ModalBody, useModal, ModalContainer } from '../../components/Modal';
import { useIsMobile } from '../../hooks/useIsMobile';
import { TextItem } from './components';
import s from './ResponsePage.module.scss';
export const ResponsePage: FC = () => {
const [isOpen, setIsOpen] = useState(false);
const onClose = useCallback(() => {
setIsOpen(false);
}, []);
const { modalIsVisible, isClosing, close } = useModal({ isOpen, onClose });
const isMobile = useIsMobile();
return (
<div className={s.ResponsePage}>
<Heading size={HeadingSize.H2} className={s.ResponsePage__title}>
Результаты по запросу
</Heading>
<div className={s.ResponsePage__container}>
{isMobile ? (
<>
<TextItem onClickSummary={() => setIsOpen(true)} />
<TextItem />
</>
) : (
<table className={s.ResponsePage__table}>
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>М. н-с.</th>
<th>М. стат.</th>
<th>М. п.</th>
<th>Рез.</th>
<th>Крат. сод.</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>file.txt</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td className={s.ResponsePage__tableSummary}>
<Link standalone={false}>Открыть</Link>
</td>
</tr>
<tr>
<td>1</td>
<td>{EMDASH}</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td>
AA+ | <span style={{ color: getPercentageColor(0.95) }}>0.95</span>
</td>
<td className={s.ResponsePage__tableSummary}>
<Link
component={'button'}
standalone={false}
onClick={(e) => {
e.stopPropagation();
setIsOpen(true);
}}>
Открыть
</Link>
</td>
</tr>
</tbody>
</table>
)}
</div>
<ModalContainer isOpen={modalIsVisible} onClose={close} isClosing={isClosing}>
<ModalBody>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys
standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to
make a type specimen book.
</ModalBody>
</ModalContainer>
</div>
);
};

View File

@ -0,0 +1,33 @@
@import 'src/app/styles/vars';
.TextItem {
background-color: $color-background-dark-100;
border-radius: $radius-medium;
padding: $spacing-small-2x;
}
.TextItem__row {
margin-bottom: $spacing-small-4x;
&:not(.TextItem__row_name) {
> span {
font-size: $font-size-14;
}
}
}
.TextItem__row_name {
margin-bottom: $spacing-small-3x;
}
.TextItem__name {
//font-size: $font-size-18;
text-transform: uppercase;
font-weight: $font-weight-600;
}
.TextItem__row_link {
margin-top: $spacing-small-3x;
margin-bottom: 0;
}

View File

@ -0,0 +1,62 @@
import clsx from 'clsx';
import { ReactFCC } from '../../../../utils/ReactFCC';
import { ETextVariants, Text } from '../../../../components/Text';
import { getPercentageColor } from '../../../../utils/getPercentageColor';
import { Link } from '../../../../components/Link';
import s from './TextItem.module.scss';
export interface TextItemProps {
/**
* Дополнительный css-класс
*/
className?: string;
onClickSummary?: () => void;
}
export const TextItem: ReactFCC<TextItemProps> = (props) => {
const { className, onClickSummary } = props;
return (
<div className={clsx(s.TextItem, className)}>
<div className={clsx(s.TextItem__row, s.TextItem__row_name)}>
{/*<Text component={'span'} variant={ETextVariants.BODY_S_MEDIUM}>*/}
{/* Имя:*/}
{/*</Text>{' '}*/}
<Text className={s.TextItem__name} component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
file.txt #1
</Text>
{/*{' | '}*/}
{/*<Text component={'span'} variant={ETextVariants.BODY_S_REGULAR}>*/}
{/* file.txt*/}
{/*</Text>*/}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. н-с: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text>{' '}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. стат: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text>{' '}
</div>
<div className={s.TextItem__row}>
<Text component={'span'} variant={ETextVariants.PROGRAMMING_CODE_REGULAR}>
М. п: AA+ | <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text>{' '}
</div>
<div className={clsx(s.TextItem__row, s.TextItem__row_link)}>
<Text component={'span'} variant={ETextVariants.BODY_S_REGULAR}>
<Link component={'button'} standalone={false} onClick={() => onClickSummary?.()}>
Краткое содержание
</Link>
</Text>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ export const TextPage: FC = () => {
Результат по методу{' '} Результат по методу{' '}
<Tooltip className={s.TextPage__tooltip} content={'Berd + Annoy'}> <Tooltip className={s.TextPage__tooltip} content={'Berd + Annoy'}>
<span className={s.TextPage__underline}>похожести</span> <span className={s.TextPage__underline}>похожести</span>
</Tooltip>{' '} </Tooltip>
: АА+ | Accuracy: <span style={{ color: getPercentageColor(0.63) }}>0.63</span> : АА+ | Accuracy: <span style={{ color: getPercentageColor(0.63) }}>0.63</span>
</Text> </Text>
</div> </div>

21
src/utils/chars.ts Normal file
View File

@ -0,0 +1,21 @@
// eslint-disable
export const LAQUO = '\u00AB'; // «
export const RAQUO = '\u00BB'; // »
export const EMDASH = '\u2014'; // —
export const THINSP = '\u2009'; //
export const QUARTERSP = '\u2005'; //
export const THIRDSP = '\u2004'; //
export const ENSP = '\u2002'; //
export const EMSP = '\u2001'; //
export const NBSP = '\u00A0'; //
export const HELLIP = '\u2026'; // …
export const LSAQUO = '\u2039'; //
export const RSAQUO = '\u203A'; //
export const RUBLE = '\u20BD'; // ₽
export const LEQ = '\u2264'; // ≤
export const NEQ = '\u2260'; // ≠
export const GEQ = '\u2265'; // ≥
export const ARROW_LEFT = '\u2190'; // ←
export const ARROW_RIGHT = '\u2192'; // →
export const MAC_OPTION = '\u2325'; // ⌥
// eslint-enable