mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-08-14 12:50:37 +03:00
Refactor SelectTree and minor UI fixes
This commit is contained in:
parent
eee96aecd1
commit
cfd886e107
|
@ -5,7 +5,7 @@ import Button from '../components/ui/Button';
|
||||||
|
|
||||||
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center antialiased clr-app' role='alert'>
|
<div className='flex flex-col gap-3 items-center antialiased clr-app' role='alert'>
|
||||||
<h1>Что-то пошло не так!</h1>
|
<h1>Что-то пошло не так!</h1>
|
||||||
<Button onClick={resetErrorBoundary} text='Попробовать еще раз' />
|
<Button onClick={resetErrorBoundary} text='Попробовать еще раз' />
|
||||||
<InfoError error={error as Error} />
|
<InfoError error={error as Error} />
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import axios, { type AxiosError } from 'axios';
|
import axios, { type AxiosError } from 'axios';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { external_urls } from '@/utils/constants';
|
|
||||||
import { isResponseHtml } from '@/utils/utils';
|
import { isResponseHtml } from '@/utils/utils';
|
||||||
|
|
||||||
import PrettyJson from '../ui/PrettyJSON';
|
import PrettyJson from '../ui/PrettyJSON';
|
||||||
import TextURL from '../ui/TextURL';
|
|
||||||
import AnimateFade from '../wrap/AnimateFade';
|
import AnimateFade from '../wrap/AnimateFade';
|
||||||
|
|
||||||
export type ErrorData = string | Error | AxiosError | undefined;
|
export type ErrorData = string | Error | AxiosError | undefined;
|
||||||
|
@ -70,12 +68,12 @@ function InfoError({ error }: InfoErrorProps) {
|
||||||
'select-text'
|
'select-text'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className='font-normal clr-text-default'>
|
<div className='font-normal clr-text-default'>
|
||||||
Пожалуйста сделайте скриншот и отправьте вместе с описанием ситуации на почту{' '}
|
<p>Пожалуйста сделайте скриншот и отправьте вместе с описанием ситуации на почту portal@acconcept.ru</p>
|
||||||
<TextURL href={external_urls.mail_portal} text='portal@acconcept.ru' />
|
|
||||||
<br />
|
<br />
|
||||||
Для продолжения работы перезагрузите страницу
|
<p>Для продолжения работы перезагрузите страницу</p>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<DescribeError error={error} />
|
<DescribeError error={error} />
|
||||||
</AnimateFade>
|
</AnimateFade>
|
||||||
);
|
);
|
||||||
|
|
112
rsconcept/frontend/src/components/ui/SelectTree.tsx
Normal file
112
rsconcept/frontend/src/components/ui/SelectTree.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { animateSideAppear } from '@/styling/animations';
|
||||||
|
import { globals } from '@/utils/constants';
|
||||||
|
|
||||||
|
import { IconDropArrow, IconPageRight } from '../Icons';
|
||||||
|
import { CProps } from '../props';
|
||||||
|
import MiniButton from './MiniButton';
|
||||||
|
import Overlay from './Overlay';
|
||||||
|
|
||||||
|
interface SelectTreeProps<ItemType> extends CProps.Styling {
|
||||||
|
items: ItemType[];
|
||||||
|
value: ItemType;
|
||||||
|
setValue: (newItem: ItemType) => void;
|
||||||
|
getParent: (item: ItemType) => ItemType;
|
||||||
|
getLabel: (item: ItemType) => string;
|
||||||
|
getDescription: (item: ItemType) => string;
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTree<ItemType>({
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
getParent,
|
||||||
|
getLabel,
|
||||||
|
getDescription,
|
||||||
|
setValue,
|
||||||
|
prefix,
|
||||||
|
...restProps
|
||||||
|
}: SelectTreeProps<ItemType>) {
|
||||||
|
const foldable = useMemo(
|
||||||
|
() => new Set(items.filter(item => getParent(item) !== item).map(item => getParent(item))),
|
||||||
|
[items, getParent]
|
||||||
|
);
|
||||||
|
const [folded, setFolded] = useState<ItemType[]>(items);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setFolded(items.filter(item => getParent(value) !== item && getParent(getParent(value)) !== item));
|
||||||
|
}, [value, getParent, items]);
|
||||||
|
|
||||||
|
const onFoldItem = useCallback(
|
||||||
|
(target: ItemType, showChildren: boolean) => {
|
||||||
|
setFolded(prev =>
|
||||||
|
items.filter(item => {
|
||||||
|
if (item === target) {
|
||||||
|
return !showChildren;
|
||||||
|
}
|
||||||
|
if (!showChildren && (getParent(item) === target || getParent(getParent(item)) === target)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return prev.includes(item);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[items, getParent]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickFold = useCallback(
|
||||||
|
(event: CProps.EventMouse, target: ItemType, showChildren: boolean) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onFoldItem(target, showChildren);
|
||||||
|
},
|
||||||
|
[onFoldItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...restProps}>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{items.map((item, index) =>
|
||||||
|
getParent(item) === item || !folded.includes(getParent(item)) ? (
|
||||||
|
<motion.div
|
||||||
|
tabIndex={-1}
|
||||||
|
key={`${prefix}${index}`}
|
||||||
|
className={clsx(
|
||||||
|
'pr-3 pl-6 py-1',
|
||||||
|
'cc-scroll-row',
|
||||||
|
'clr-controls clr-hover',
|
||||||
|
'cursor-pointer',
|
||||||
|
value === item && 'clr-selected'
|
||||||
|
)}
|
||||||
|
data-tooltip-id={globals.tooltip}
|
||||||
|
data-tooltip-content={getDescription(item)}
|
||||||
|
onClick={() => setValue(item)}
|
||||||
|
initial={{ ...animateSideAppear.initial }}
|
||||||
|
animate={{ ...animateSideAppear.animate }}
|
||||||
|
exit={{ ...animateSideAppear.exit }}
|
||||||
|
>
|
||||||
|
{foldable.has(item) ? (
|
||||||
|
<Overlay position='left-[-1.3rem]' className={clsx(!folded.includes(item) && 'top-[0.1rem]')}>
|
||||||
|
<MiniButton
|
||||||
|
tabIndex={-1}
|
||||||
|
noPadding
|
||||||
|
noHover
|
||||||
|
icon={!folded.includes(item) ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
|
||||||
|
onClick={event => handleClickFold(event, item, folded.includes(item))}
|
||||||
|
/>
|
||||||
|
</Overlay>
|
||||||
|
) : null}
|
||||||
|
{getParent(item) === item ? getLabel(item) : `- ${getLabel(item).toLowerCase()}`}
|
||||||
|
</motion.div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectTree;
|
|
@ -32,7 +32,13 @@ function Tooltip({
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
delayHide={100}
|
delayHide={100}
|
||||||
opacity={0.97}
|
opacity={0.97}
|
||||||
className={clsx('overflow-auto sm:overflow-hidden overscroll-contain', 'border shadow-md', layer, className)}
|
className={clsx(
|
||||||
|
'overflow-auto sm:overflow-hidden overscroll-contain',
|
||||||
|
'border shadow-md',
|
||||||
|
'text-balance',
|
||||||
|
layer,
|
||||||
|
className
|
||||||
|
)}
|
||||||
classNameArrow={layer}
|
classNameArrow={layer}
|
||||||
style={{ ...{ paddingTop: '2px', paddingBottom: '2px' }, ...style }}
|
style={{ ...{ paddingTop: '2px', paddingBottom: '2px' }, ...style }}
|
||||||
variant={darkMode ? 'dark' : 'light'}
|
variant={darkMode ? 'dark' : 'light'}
|
||||||
|
|
|
@ -6,12 +6,13 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { IconMenuFold, IconMenuUnfold } from '@/components/Icons';
|
import { IconMenuFold, IconMenuUnfold } from '@/components/Icons';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import SelectTree from '@/components/ui/SelectTree';
|
||||||
import { useConceptOptions } from '@/context/OptionsContext';
|
import { useConceptOptions } from '@/context/OptionsContext';
|
||||||
import useDropdown from '@/hooks/useDropdown';
|
import useDropdown from '@/hooks/useDropdown';
|
||||||
import { HelpTopic } from '@/models/miscellaneous';
|
import { HelpTopic, topicParent } from '@/models/miscellaneous';
|
||||||
import { animateSlideLeft } from '@/styling/animations';
|
import { animateSlideLeft } from '@/styling/animations';
|
||||||
|
import { prefixes } from '@/utils/constants';
|
||||||
import TopicsTree from './TopicsTree';
|
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
|
||||||
|
|
||||||
interface TopicsDropdownProps {
|
interface TopicsDropdownProps {
|
||||||
activeTopic: HelpTopic;
|
activeTopic: HelpTopic;
|
||||||
|
@ -22,7 +23,7 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
|
||||||
const menu = useDropdown();
|
const menu = useDropdown();
|
||||||
const { noNavigation, calculateHeight } = useConceptOptions();
|
const { noNavigation, calculateHeight } = useConceptOptions();
|
||||||
|
|
||||||
const selectTheme = useCallback(
|
const handleSelectTopic = useCallback(
|
||||||
(topic: HelpTopic) => {
|
(topic: HelpTopic) => {
|
||||||
menu.hide();
|
menu.hide();
|
||||||
onChangeTopic(topic);
|
onChangeTopic(topic);
|
||||||
|
@ -65,7 +66,15 @@ function TopicsDropdown({ activeTopic, onChangeTopic }: TopicsDropdownProps) {
|
||||||
animate={menu.isOpen ? 'open' : 'closed'}
|
animate={menu.isOpen ? 'open' : 'closed'}
|
||||||
variants={animateSlideLeft}
|
variants={animateSlideLeft}
|
||||||
>
|
>
|
||||||
<TopicsTree activeTopic={activeTopic} onChangeTopic={selectTheme} />
|
<SelectTree
|
||||||
|
items={Object.values(HelpTopic).map(item => item as HelpTopic)}
|
||||||
|
value={activeTopic}
|
||||||
|
setValue={handleSelectTopic}
|
||||||
|
prefix={prefixes.topic_list}
|
||||||
|
getParent={item => topicParent.get(item) ?? item}
|
||||||
|
getLabel={labelHelpTopic}
|
||||||
|
getDescription={describeHelpTopic}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import SelectTree from '@/components/ui/SelectTree';
|
||||||
import { useConceptOptions } from '@/context/OptionsContext';
|
import { useConceptOptions } from '@/context/OptionsContext';
|
||||||
import { HelpTopic } from '@/models/miscellaneous';
|
import { HelpTopic, topicParent } from '@/models/miscellaneous';
|
||||||
|
import { prefixes } from '@/utils/constants';
|
||||||
import TopicsTree from './TopicsTree';
|
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
|
||||||
|
|
||||||
interface TopicsStaticProps {
|
interface TopicsStaticProps {
|
||||||
activeTopic: HelpTopic;
|
activeTopic: HelpTopic;
|
||||||
|
@ -13,7 +14,14 @@ interface TopicsStaticProps {
|
||||||
function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
|
function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
|
||||||
const { calculateHeight } = useConceptOptions();
|
const { calculateHeight } = useConceptOptions();
|
||||||
return (
|
return (
|
||||||
<div
|
<SelectTree
|
||||||
|
items={Object.values(HelpTopic).map(item => item as HelpTopic)}
|
||||||
|
value={activeTopic}
|
||||||
|
setValue={onChangeTopic}
|
||||||
|
prefix={prefixes.topic_list}
|
||||||
|
getParent={item => topicParent.get(item) ?? item}
|
||||||
|
getLabel={labelHelpTopic}
|
||||||
|
getDescription={describeHelpTopic}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'sticky top-0 left-0',
|
'sticky top-0 left-0',
|
||||||
'w-[14.5rem] cc-scroll-y',
|
'w-[14.5rem] cc-scroll-y',
|
||||||
|
@ -24,9 +32,7 @@ function TopicsStatic({ activeTopic, onChangeTopic }: TopicsStaticProps) {
|
||||||
'select-none'
|
'select-none'
|
||||||
)}
|
)}
|
||||||
style={{ maxHeight: calculateHeight('2.25rem + 2px') }}
|
style={{ maxHeight: calculateHeight('2.25rem + 2px') }}
|
||||||
>
|
/>
|
||||||
<TopicsTree activeTopic={activeTopic} onChangeTopic={onChangeTopic} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,127 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useCallback, useLayoutEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { IconDropArrow, IconPageRight } from '@/components/Icons';
|
|
||||||
import { CProps } from '@/components/props';
|
|
||||||
import MiniButton from '@/components/ui/MiniButton';
|
|
||||||
import Overlay from '@/components/ui/Overlay';
|
|
||||||
import { foldableTopics, HelpTopic, topicParent } from '@/models/miscellaneous';
|
|
||||||
import { animateSideAppear } from '@/styling/animations';
|
|
||||||
import { globals, prefixes } from '@/utils/constants';
|
|
||||||
import { describeHelpTopic, labelHelpTopic } from '@/utils/labels';
|
|
||||||
|
|
||||||
interface TopicsTreeProps {
|
|
||||||
activeTopic: HelpTopic;
|
|
||||||
onChangeTopic: (newTopic: HelpTopic) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopicsTree({ activeTopic, onChangeTopic }: TopicsTreeProps) {
|
|
||||||
const [topicFolded, setFolded] = useState<Map<HelpTopic, boolean>>(
|
|
||||||
new Map(
|
|
||||||
Object.values(HelpTopic).map(value => {
|
|
||||||
const topic = value as HelpTopic;
|
|
||||||
return [topic, true];
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setFolded(
|
|
||||||
new Map(
|
|
||||||
Object.values(HelpTopic).map(value => {
|
|
||||||
const topic = value as HelpTopic;
|
|
||||||
return [
|
|
||||||
topic,
|
|
||||||
topicParent.get(activeTopic) !== topic && topicParent.get(topicParent.get(activeTopic)!) !== topic
|
|
||||||
];
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [activeTopic]);
|
|
||||||
|
|
||||||
const onFoldTopic = useCallback(
|
|
||||||
(target: HelpTopic, showChildren: boolean) => {
|
|
||||||
if (topicFolded.get(target) === !showChildren) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFolded(
|
|
||||||
new Map(
|
|
||||||
Object.values(HelpTopic).map(value => {
|
|
||||||
const topic = value as HelpTopic;
|
|
||||||
if (topic === target) {
|
|
||||||
return [topic, !showChildren];
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!showChildren &&
|
|
||||||
(topicParent.get(topic) === target || topicParent.get(topicParent.get(topic)!) === target)
|
|
||||||
) {
|
|
||||||
return [topic, true];
|
|
||||||
}
|
|
||||||
const oldValue = topicFolded.get(topic)!;
|
|
||||||
return [topic, oldValue];
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[topicFolded]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClickFold = useCallback(
|
|
||||||
(event: CProps.EventMouse, topic: HelpTopic, showChildren: boolean) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onFoldTopic(topic, showChildren);
|
|
||||||
},
|
|
||||||
[onFoldTopic]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{Object.values(HelpTopic).map((topic, index) => {
|
|
||||||
const parent = topicParent.get(topic);
|
|
||||||
if (parent !== topic && topicFolded.get(topicParent.get(topic)!)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const isFoldable = !!foldableTopics.find(id => id === topic);
|
|
||||||
const isFolded = topicFolded.get(topic)!;
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
tabIndex={-1}
|
|
||||||
key={`${prefixes.topic_list}${index}`}
|
|
||||||
className={clsx(
|
|
||||||
'pr-3 pl-6 py-1',
|
|
||||||
'cc-scroll-row',
|
|
||||||
'clr-controls clr-hover',
|
|
||||||
'cursor-pointer',
|
|
||||||
activeTopic === topic && 'clr-selected'
|
|
||||||
)}
|
|
||||||
data-tooltip-id={globals.tooltip}
|
|
||||||
data-tooltip-content={describeHelpTopic(topic)}
|
|
||||||
onClick={() => onChangeTopic(topic)}
|
|
||||||
initial={{ ...animateSideAppear.initial }}
|
|
||||||
animate={{ ...animateSideAppear.animate }}
|
|
||||||
exit={{ ...animateSideAppear.exit }}
|
|
||||||
>
|
|
||||||
{isFoldable ? (
|
|
||||||
<Overlay position='left-[-1.3rem]' className={clsx(!isFolded && 'top-[0.1rem]')}>
|
|
||||||
<MiniButton
|
|
||||||
tabIndex={-1}
|
|
||||||
noPadding
|
|
||||||
noHover
|
|
||||||
icon={!isFolded ? <IconDropArrow size='1rem' /> : <IconPageRight size='1.25rem' />}
|
|
||||||
onClick={event => handleClickFold(event, topic, isFolded)}
|
|
||||||
/>
|
|
||||||
</Overlay>
|
|
||||||
) : null}
|
|
||||||
{topicParent.get(topic) === topic ? labelHelpTopic(topic) : `- ${labelHelpTopic(topic).toLowerCase()}`}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TopicsTree;
|
|
|
@ -210,22 +210,32 @@ export const animateSideView = {
|
||||||
|
|
||||||
export const animateSideAppear = {
|
export const animateSideAppear = {
|
||||||
initial: {
|
initial: {
|
||||||
clipPath: 'inset(0% 100% 0% 0%)'
|
height: 0,
|
||||||
|
opacity: 0
|
||||||
},
|
},
|
||||||
animate: {
|
animate: {
|
||||||
clipPath: 'inset(0% 0% 0% 0%)',
|
height: 'auto',
|
||||||
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
type: 'spring',
|
height: {
|
||||||
bounce: 0,
|
duration: 0.25
|
||||||
duration: 0.3
|
},
|
||||||
|
opacity: {
|
||||||
|
delay: 0.25,
|
||||||
|
duration: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
exit: {
|
exit: {
|
||||||
clipPath: 'inset(0% 100% 0% 0%)',
|
height: 0,
|
||||||
|
opacity: 0,
|
||||||
transition: {
|
transition: {
|
||||||
type: 'spring',
|
height: {
|
||||||
bounce: 0,
|
duration: 0.25
|
||||||
duration: 0.3
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue
Block a user