Refactor navigation and library search

This commit is contained in:
IRBorisov 2023-08-26 19:39:49 +03:00
parent 743ddf298e
commit fc7af76cfe
17 changed files with 174 additions and 192 deletions

View File

@ -7,9 +7,8 @@ function Footer() {
<footer className='z-50 px-4 pt-2 pb-4 text-sm select-none whitespace-nowrap clr-footer'>
<div className='flex items-stretch justify-center w-full gap-4 mx-auto'>
<div className='underline'>
<Link to='/library' tabIndex={-1}>Библиотека</Link> <br/>
<Link to='/manuals' tabIndex={-1}>Справка</Link> <br/>
<Link to='/library?filter=common' tabIndex={-1}>Библиотека</Link> <br/>
</div>
<div className=''>
<a href={urls.concept} tabIndex={-1} className='underline'>Центр Концепт</a>

View File

@ -1,12 +1,25 @@
import { EducationIcon, EyeIcon, GroupIcon } from '../Icons';
function HelpLibrary() {
return (
<div className=''>
<h1>Библиотека концептуальных схем</h1>
<p>В библиотеки собраны различные концептуальные схемы. Группировка и классификации схем на данный момент не проводится.</p>
<p>На текущем этапе развития Портала происходит первичное наполнение Библиотеки концептуальными схемами.</p>
<p>Доступен поиск схемы в Библиотеке с помощью инструментов, расположенных в верхней части страницы.</p>
<p>Аттрибут "общедоступная схема" делает схему видимой в разделе библиотека.</p>
<p>Аттрибут "библиотечная схема" позволяет выделить неизменяемые схемы, используемые как примеры и основа последующих разработок.</p>
<p>В библиотеки собраны различные концептуальные схемы.</p>
<p>Группировка и классификации схем на данный момент не проводится.</p>
<p>На текущем этапе происходит наполнение Библиотеки концептуальными схемами.</p>
<p>Поиск осуществлеяется с помощью инструментов в верхней части страницы.</p>
<div className='flex items-center gap-2'>
<EyeIcon size={4}/>
<p>Аттрибут <b>отслеживаемая</b> обозначает отслеживание схемы.</p>
</div>
<div className='flex items-center gap-2'>
<GroupIcon size={4}/>
<p>Аттрибут <b>общедоступная</b> делает схему видимой в разделе библиотека.</p>
</div>
<div className='flex items-center gap-2'>
<EducationIcon size={4}/>
<p>Аттрибут <b>библиотечная</b> выделяет неизменяемые стандартные схемы.</p>
</div>
</div>
);
}

View File

@ -7,8 +7,8 @@ interface LogoProps {
function Logo({ title }: LogoProps) {
return (
<Link to='/' className='flex items-center mr-4' tabIndex={-1}>
<img src='/favicon.svg' className='min-h-[2.5rem] mr-2 min-w-[2.5rem]' alt=''/>
<span className='self-center hidden text-2xl font-semibold lg:block whitespace-nowrap dark:text-white'>{title}</span>
<img src='/favicon.svg' className='min-h-[2rem] mr-2 min-w-[2rem]' alt=''/>
<span className='self-center hidden text-xl font-semibold lg:block whitespace-nowrap dark:text-white'>{title}</span>
</Link>
);
}

View File

@ -4,9 +4,7 @@ import { useConceptTheme } from '../../context/ThemeContext';
import { EducationIcon, LibraryIcon } from '../Icons';
import Logo from './Logo'
import NavigationButton from './NavigationButton';
import TopSearch from './TopSearch';
import UserMenu from './UserMenu';
import UserTools from './UserTools';
function Navigation () {
const navigate = useNavigate();
@ -16,11 +14,11 @@ function Navigation () {
const navigateHelp = () => { navigate('/manuals') };
return (
<nav className='sticky top-0 left-0 right-0 z-50 h-fit'>
<nav className='sticky top-0 left-0 right-0 z-50 select-none h-fit'>
{!noNavigation &&
<button
title='Скрыть навигацию'
className='absolute top-0 right-0 z-[60] w-[1.2rem] h-[4rem] border-b-2 border-l-2 clr-nav rounded-none'
className='absolute top-0 right-0 z-[60] w-[1.2rem] border-b-2 border-l-2 clr-nav rounded-none'
onClick={toggleNoNavigation}
>
<p>{'>'}</p><p>{'>'}</p>
@ -28,22 +26,30 @@ function Navigation () {
{noNavigation &&
<button
title='Показать навигацию'
className='absolute top-0 right-0 z-[60] w-[4rem] h-[1.6rem] border-b-2 border-l-2 clr-nav rounded-none'
className='absolute top-0 right-0 z-[60] px-1 h-[1.6rem] border-b-2 border-l-2 clr-nav rounded-none'
onClick={toggleNoNavigation}
>
{''}
</button>}
{!noNavigation &&
<div className='pr-6 pl-2 py-2.5 h-[4rem] flex items-center justify-between border-b-2 clr-nav rounded-none'>
<div className='flex items-start justify-start select-none'>
<div className='flex items-center justify-between py-1 pl-2 pr-6 border-b-2 rounded-none clr-nav'>
<div className='flex items-center justify-start'>
<Logo title='КонцептПортал' />
<TopSearch />
</div>
<div className='flex items-center'>
<UserTools/>
<div className='flex items-center pl-2'>
<NavigationButton icon={<LibraryIcon />} description='Общие схемы' onClick={navigateCommon} />
<NavigationButton icon={<EducationIcon />} description='Справка' onClick={navigateHelp} />
<NavigationButton
text='Библиотека'
description='Библиотека концептуальных схем'
icon={<LibraryIcon />}
onClick={navigateCommon}
/>
<NavigationButton
text='Справка'
description='Справочные материалы и обучение'
icon={<EducationIcon />}
onClick={navigateHelp}
/>
<UserMenu />
</div>
</div>

View File

@ -1,22 +1,21 @@
interface NavigationButtonProps {
id?: string
text?: string
icon: React.ReactNode
description?: string
colorClass?: string
onClick?: () => void
}
const defaultColors = 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
function NavigationButton({ id, icon, description, colorClass = defaultColors, onClick }: NavigationButtonProps) {
function NavigationButton({ id, icon, description, onClick, text }: NavigationButtonProps) {
return (
<button id={id}
title={description}
type='button'
onClick={onClick}
className={'min-w-fit p-2 mr-1 focus:ring-4 rounded-lg focus:ring-gray-300 dark:focus:ring-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 ' + colorClass}
className='flex gap-1 p-2 mr-1 rounded-lg min-w-fit whitespace-nowrap clr-btn-nav'
>
{icon}
{icon && <span>{icon}</span>}
{text && <span className='font-semibold'>{text}</span>}
</button>
);
}

View File

@ -1,41 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { useNavSearch } from '../../context/NavSearchContext';
import { MagnifyingGlassIcon } from '../Icons';
function TopSearch() {
const navigate = useNavigate();
const { query, setQuery } = useNavSearch();
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
const url = new URL(window.location.href);
if (!url.href.includes('/library')) {
event.preventDefault();
navigate('/library?filter=query');
}
}
}
return (
<div className='hidden md:block md:pl-2'>
<div className='relative md:w-96'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<MagnifyingGlassIcon />
</div>
<input
type='text'
name='email'
id='topbar-search'
value={query}
className='text-sm block w-full pl-10 p-2.5 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-600 dark:border-gray-400 dark:placeholder-gray-200 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500'
placeholder='Поиск схемы...'
onChange={data => setQuery(data.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
);
}
export default TopSearch;

View File

@ -8,7 +8,7 @@ import UserDropdown from './UserDropdown';
function LoginRef() {
return (
<Link to='login' className='inline-block text-sm font-bold hover:underline'>
<Link to='login' className='inline-block h-full px-1 py-2 font-semibold rounded-lg hover:underline clr-btn-nav text-primary'>
Войти...
</Link>
);
@ -19,6 +19,7 @@ function UserMenu() {
const menu = useDropdown();
return (
<div ref={menu.ref}>
<div className='w-[4.2rem] flex justify-end'>
{ !user && <LoginRef />}
{ user &&
<NavigationButton
@ -26,6 +27,7 @@ function UserMenu() {
description={`Пользователь ${user?.username}`}
onClick={menu.toggle}
/>}
</div>
{ user && menu.isActive &&
<UserDropdown
hideDropdown={() => { menu.hide(); }}

View File

@ -1,46 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import ConceptTooltip from '../Common/ConceptTooltip';
import TextURL from '../Common/TextURL';
import { PlusIcon, SquaresIcon } from '../Icons';
import NavigationButton from './NavigationButton';
function UserTools() {
const navigate = useNavigate();
const { user } = useAuth();
const navigateCreateRSForm = () => navigate('/rsform-create');
const navigateMyWork = () => navigate('/library?filter=personal');
return (
<div className='flex items-center px-2 border-r-2 clr-border-nav'>
<span>
{ user &&
<NavigationButton
description='Новая схема'
icon={<PlusIcon />}
onClick={navigateCreateRSForm}
colorClass='text-url'
/>}
{ !user &&
<NavigationButton id='items-nav-help'
icon={<PlusIcon />}
/>}
<ConceptTooltip anchorSelect='#items-nav-help' clickable>
<div className='flex flex-col cursor-default'>
<p>Создание и редактирование концептуальных схем</p>
<p>доступно только <b>зарегистрированным пользователям</b></p>
<div className='flex flex-col self-center'>
<TextURL text='Войти в систему' href='/login'/>
<TextURL text='Зарегистрироваться' href='/signup'/>
</div>
</div>
</ConceptTooltip>
</span>
{ user && <NavigationButton icon={<SquaresIcon />} description='Мои схемы' onClick={navigateMyWork} /> }
</div>
);
}
export default UserTools;

View File

@ -1,38 +0,0 @@
import { createContext, useCallback, useContext, useState } from 'react';
interface INavSearchContext {
query: string
setQuery: (value: string) => void
resetQuery: () => void
}
const NavSearchContext = createContext<INavSearchContext | null>(null);
export const useNavSearch = () => {
const context = useContext(NavSearchContext);
if (!context) {
throw new Error(
'useNavSearch has to be used within <NavSearchState.Provider>'
);
}
return context;
}
interface NavSearchStateProps {
children: React.ReactNode
}
export const NavSearchState = ({ children }: NavSearchStateProps) => {
const [query, setQuery] = useState('');
const resetQuery = useCallback(() => setQuery(''), []);
return (
<NavSearchContext.Provider value={{
query,
setQuery,
resetQuery: resetQuery
}}>
{children}
</NavSearchContext.Provider>
);
}

View File

@ -94,7 +94,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
const isTracking = useMemo(
() => {
if (!schema || !user) {
if (!user || !schema || !user.id) {
return false;
}
return schema.subscribers.includes(user.id);
@ -165,7 +165,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: () => {
if (!schema.subscribers.includes(user.id)) {
if (user.id && !schema.subscribers.includes(user.id)) {
schema.subscribers.push(user.id);
}
if (!user.subscriptions.includes(schema.id)) {
@ -188,7 +188,7 @@ export const RSFormState = ({ schemaID, children }: RSFormStateProps) => {
setLoading: setProcessing,
onError: error => setError(error),
onSuccess: () => {
if (schema.subscribers.includes(user.id)) {
if (user.id && schema.subscribers.includes(user.id)) {
schema.subscribers.splice(schema.subscribers.indexOf(user.id), 1);
}
if (user.subscriptions.includes(schema.id)) {

View File

@ -47,14 +47,14 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
const mainHeight = useMemo(
() => {
return !noNavigation ?
'calc(100vh - 9.2rem)'
'calc(100vh - 8.6rem)'
: '100vh';
}, [noNavigation]);
const viewportHeight = useMemo(
() => {
return !noNavigation ?
'calc(100vh - 4.5rem)'
'calc(100vh - 3.9rem)'
: '100vh';
}, [noNavigation]);

View File

@ -86,6 +86,10 @@
@apply hover:bg-gray-200 hover:text-gray-700 dark:hover:text-white dark:hover:bg-gray-500
}
.clr-btn-nav {
@apply text-gray-500 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white focus:ring-gray-300 dark:focus:ring-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600
}
.clr-btn-primary {
@apply text-white bg-blue-400 hover:bg-blue-600 dark:bg-orange-600 dark:hover:bg-orange-400 disabled:bg-gray-400 dark:disabled:bg-gray-600
}
@ -115,7 +119,7 @@
}
.text-url {
@apply hover:text-blue-600 text-blue-400 dark:text-orange-600 dark:hover:text-orange-400
@apply hover:text-blue-600 text-blue-400 dark:text-orange-500 dark:hover:text-orange-300
}
.text-red {

View File

@ -10,7 +10,6 @@ import App from './App.tsx'
import ErrorFallback from './components/ErrorFallback.tsx';
import { AuthState } from './context/AuthContext.tsx';
import { LibraryState } from './context/LibraryContext.tsx';
import { NavSearchState } from './context/NavSearchContext.tsx';
import { ThemeState } from './context/ThemeContext.tsx';
import { UsersState } from './context/UsersContext.tsx';
import { initBackend } from './utils/backendAPI.ts';
@ -36,7 +35,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
>
<IntlProvider locale='ru' defaultLocale='ru'>
<ThemeState>
<NavSearchState>
<AuthState>
<UsersState>
<LibraryState>
@ -46,7 +44,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</LibraryState>
</UsersState>
</AuthState>
</NavSearchState>
</ThemeState>
</IntlProvider>
</ErrorBoundary>

View File

@ -0,0 +1,70 @@
import { useLayoutEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { MagnifyingGlassIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import { ILibraryFilter } from '../../utils/models';
interface SearchPanelProps {
filter: ILibraryFilter
setFilter: React.Dispatch<React.SetStateAction<ILibraryFilter>>
}
function SearchPanel({ filter, setFilter }: SearchPanelProps) {
const search = useLocation().search;
const { user } = useAuth();
const [query, setQuery] = useState('')
useLayoutEffect(() => {
const filterType = new URLSearchParams(search).get('filter');
if (filterType === 'common') {
setQuery('');
setFilter({
is_common: true
});
} else if (filterType === 'personal' && user) {
setQuery('');
setFilter({
ownedBy: user.id!
});
}
}, [user, search, setQuery, setFilter]);
return (
<div className='sticky top-0 left-0 right-0 z-10 flex justify-center w-full border-b clr-bg-pop'>
<div className='relative w-96'>
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
<MagnifyingGlassIcon />
</div>
<input
type='text'
value={query}
className='w-full p-2 pl-10 text-sm outline-none clr-bg-pop border-x clr-border'
placeholder='Поиск схемы...'
onChange={data => setQuery(data.target.value)}
/>
</div>
</div>
);
}
export default SearchPanel;
{/* <div className='sticky top-0 left-0 right-0 z-10 flex items-start justify-between w-full gap-1 px-2 py-1 bg-white border-b rounded clr-bg-pop clr-border'>
<MatchModePicker
value={filterMatch}
onChange={setFilterMatch}
/>
<input type='text'
className='w-full px-2 bg-white outline-none select-none hover:text-clip clr-bg-pop'
placeholder='наберите текст фильтра'
value={filterText}
onChange={event => setFilterText(event.target.value)}
/>
<DependencyModePicker
value={filterSource}
onChange={setFilterSource}
/>
</div> */}

View File

@ -3,20 +3,22 @@ import { useIntl } from 'react-intl';
import { useNavigate } from 'react-router-dom';
import ConceptDataTable from '../../components/Common/ConceptDataTable';
import ConceptTooltip from '../../components/Common/ConceptTooltip';
import MiniButton from '../../components/Common/MiniButton';
import TextURL from '../../components/Common/TextURL';
import { EducationIcon, EyeIcon, GroupIcon } from '../../components/Icons';
import HelpLibrary from '../../components/Help/HelpLibrary';
import { EducationIcon, EyeIcon, GroupIcon, HelpIcon, PlusIcon } from '../../components/Icons';
import { useAuth } from '../../context/AuthContext';
import { useNavSearch } from '../../context/NavSearchContext';
import { useUsers } from '../../context/UsersContext';
import { prefixes } from '../../utils/constants';
import { ILibraryItem } from '../../utils/models'
import { ILibraryItem } from '../../utils/models';
interface ViewLibraryProps {
items: ILibraryItem[]
cleanQuery: () => void
}
function ViewLibrary({ items }: ViewLibraryProps) {
const { resetQuery: cleanQuery } = useNavSearch();
function ViewLibrary({ items, cleanQuery }: ViewLibraryProps) {
const { user } = useAuth();
const navigate = useNavigate();
const intl = useIntl();
@ -24,6 +26,10 @@ function ViewLibrary({ items }: ViewLibraryProps) {
const openRSForm = (item: ILibraryItem) => navigate(`/rsforms/${item.id}`);
function handleCreateNew() {
navigate('/rsform-create');
}
const columns = useMemo(
() => [
{
@ -83,6 +89,26 @@ function ViewLibrary({ items }: ViewLibraryProps) {
], [intl, getUserLabel, user]);
return (
<div>
<div className='relative w-full'>
<div className='absolute top-0 left-0 z-20 flex gap-1 mt-2 ml-1'>
<MiniButton
onClick={handleCreateNew}
tooltip='Создать схему'
noHover
disabled={!user || !user.id}
icon={<PlusIcon color={!user || !user.id ? '' : 'text-primary'} size={5} />}
/>
<div id='library-help' className='py-2'>
<HelpIcon color='text-primary' size={5} />
</div>
<ConceptTooltip anchorSelect='#library-help'>
<div className='max-w-[35rem]'>
<HelpLibrary />
</div>
</ConceptTooltip>
</div>
</div>
<ConceptDataTable
columns={columns}
data={items}
@ -111,6 +137,7 @@ function ViewLibrary({ items }: ViewLibraryProps) {
paginationRowsPerPageOptions={[10, 20, 30, 50, 100]}
onRowClicked={openRSForm}
/>
</div>
);
}

View File

@ -1,49 +1,39 @@
import { useLayoutEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import BackendError from '../../components/BackendError'
import { Loader } from '../../components/Common/Loader'
import { useAuth } from '../../context/AuthContext';
import { useLibrary } from '../../context/LibraryContext';
import { useNavSearch } from '../../context/NavSearchContext';
import { ILibraryFilter, ILibraryItem } from '../../utils/models';
import SearchPanel from './SearchPanel';
import ViewLibrary from './ViewLibrary';
function LibraryPage() {
const search = useLocation().search;
const { query, resetQuery: cleanQuery } = useNavSearch();
const { user } = useAuth();
const library = useLibrary();
const [ filterParams, setFilterParams ] = useState<ILibraryFilter>({});
const [ items, setItems ] = useState<ILibraryItem[]>([]);
useLayoutEffect(() => {
const filterType = new URLSearchParams(search).get('filter');
if (filterType === 'common') {
cleanQuery();
setFilterParams({
is_common: true
});
} else if (filterType === 'personal' && user) {
cleanQuery();
setFilterParams({
ownedBy: user.id!
});
}
}, [user, search, cleanQuery]);
useLayoutEffect(() => {
const filter = filterParams;
filterParams.queryMeta = query ? query: undefined;
setItems(library.filter(filter));
}, [query, library, filterParams]);
}, [library, filterParams]);
return (
<div className='w-full'>
{ library.loading && <Loader /> }
{ library.error && <BackendError error={library.error} />}
{ !library.loading && library.items && <ViewLibrary items={items} /> }
{ !library.loading && library.items &&
<div className='flex flex-col w-full'>
<SearchPanel
filter={filterParams}
setFilter={setFilterParams}
/>
<ViewLibrary
cleanQuery={() => setFilterParams({})}
items={items}
/>
</div>
}
</div>
);
}

View File

@ -337,8 +337,8 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
const canvasHeight = useMemo(
() => {
return !noNavigation ?
'calc(100vh - 10.5rem)'
: 'calc(100vh - 2rem)';
'calc(100vh - 9.8rem)'
: 'calc(100vh - 1.8rem)';
}, [noNavigation]);
const dismissedStyle = useCallback(