dev.redesign #18
14 changed files with 147 additions and 79 deletions
39
src/Components/Bar/Bar.tsx
Normal file
39
src/Components/Bar/Bar.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { ClockIcon, CubeIcon, MapIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { BarIcon } from './BarIcon';
|
||||
|
||||
interface IBarProps {
|
||||
isSidebarOpen: boolean
|
||||
isElementsSidebarOpen: boolean
|
||||
isHistoryOpen: boolean
|
||||
ToggleSidebar: () => void
|
||||
ToggleElementsSidebar: () => void
|
||||
ToggleTimeline: () => void
|
||||
}
|
||||
|
||||
export const BAR_WIDTH = 64; // 4rem
|
||||
|
||||
export const Bar: React.FC<IBarProps> = (props) => {
|
||||
return (
|
||||
<div className='fixed z-20 flex flex-col top-0 left-0 h-screen w-16 bg-slate-100'>
|
||||
<BarIcon
|
||||
isActive={props.isSidebarOpen}
|
||||
title='Components'
|
||||
onClick={() => props.ToggleSidebar()}>
|
||||
<CubeIcon className='heroicon'/>
|
||||
</BarIcon>
|
||||
<BarIcon
|
||||
isActive={props.isElementsSidebarOpen}
|
||||
title='Map'
|
||||
onClick={() => props.ToggleElementsSidebar()}>
|
||||
<MapIcon className='heroicon'/>
|
||||
</BarIcon>
|
||||
<BarIcon
|
||||
isActive={props.isHistoryOpen}
|
||||
title='Timeline'
|
||||
onClick={() => props.ToggleTimeline()}>
|
||||
<ClockIcon className='heroicon'/>
|
||||
</BarIcon>
|
||||
</div>
|
||||
);
|
||||
};
|
22
src/Components/Bar/BarIcon.tsx
Normal file
22
src/Components/Bar/BarIcon.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
|
||||
interface IBarIconProps {
|
||||
title: string
|
||||
children: React.ReactElement
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const BarIcon: React.FC<IBarIconProps> = (props) => {
|
||||
const isActiveClasses = props.isActive ? 'border-l-4 border-blue-500 bg-slate-200' : '';
|
||||
return (
|
||||
<button
|
||||
className={`bar-btn group ${isActiveClasses}`}
|
||||
title={props.title}
|
||||
onClick={() => props.onClick()}
|
||||
>
|
||||
<span className='sidebar-tooltip group-hover:scale-100'>{props.title}</span>
|
||||
{ props.children }
|
||||
</button>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, test, expect, vi } from 'vitest';
|
||||
import { describe, expect, vi } from 'vitest';
|
||||
import * as React from 'react';
|
||||
import { fireEvent, render, screen } from '../../utils/test-utils';
|
||||
import { ElementsSidebar } from './ElementsSidebar';
|
||||
|
@ -11,7 +11,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={null}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={() => {}}
|
||||
/>);
|
||||
|
@ -39,7 +38,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={null}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={() => {}}
|
||||
/>);
|
||||
|
@ -69,7 +67,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={MainContainer}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={() => {}}
|
||||
/>);
|
||||
|
@ -154,7 +151,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={MainContainer}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={() => {}}
|
||||
/>);
|
||||
|
@ -207,7 +203,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={SelectedContainer}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={selectContainer}
|
||||
/>);
|
||||
|
@ -229,7 +224,6 @@ describe.concurrent('Elements sidebar', () => {
|
|||
isOpen={true}
|
||||
isHistoryOpen={false}
|
||||
SelectedContainer={SelectedContainer}
|
||||
onClick={() => {}}
|
||||
onPropertyChange={() => {}}
|
||||
selectContainer={selectContainer}
|
||||
/>);
|
||||
|
|
|
@ -9,7 +9,6 @@ interface IElementsSidebarProps {
|
|||
isOpen: boolean
|
||||
isHistoryOpen: boolean
|
||||
SelectedContainer: IContainerModel | null
|
||||
onClick: () => void
|
||||
onPropertyChange: (key: string, value: string) => void
|
||||
selectContainer: (container: IContainerModel) => void
|
||||
}
|
||||
|
@ -42,8 +41,8 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
|
|||
const selectedClass: string = this.props.SelectedContainer !== undefined &&
|
||||
this.props.SelectedContainer !== null &&
|
||||
this.props.SelectedContainer.properties.id === container.properties.id
|
||||
? 'bg-blue-500 hover:bg-blue-600'
|
||||
: 'bg-slate-400 hover:bg-slate-600';
|
||||
? 'border-l-4 border-blue-500 bg-slate-400/60 hover:bg-slate-400'
|
||||
: 'bg-slate-300/60 hover:bg-slate-300';
|
||||
containerRows.push(
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
|
@ -65,14 +64,11 @@ export class ElementsSidebar extends React.PureComponent<IElementsSidebarProps>
|
|||
});
|
||||
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-300 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<button className='close-button bg-slate-400 hover:bg-slate-600 justify-start' onClick={this.props.onClick}>
|
||||
× Close
|
||||
</button>
|
||||
<div className='bg-slate-500 sidebar-row'>
|
||||
<div className={`fixed flex flex-col bg-slate-100 text-gray-800 transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<div className='bg-slate-100 font-bold sidebar-title'>
|
||||
Elements
|
||||
</div>
|
||||
<div className='overflow-y-auto overflow-x-hidden text-slate-200 flex-grow divide-y divide-solid divide-slate-500'>
|
||||
<div className='overflow-y-auto overflow-x-hidden text-gray-800 flex-grow'>
|
||||
{ containerRows }
|
||||
</div>
|
||||
<Properties properties={this.props.SelectedContainer?.properties} onChange={this.props.onPropertyChange}></Properties>
|
||||
|
|
|
@ -21,7 +21,7 @@ const FloatingButton: React.FC<IFloatingButtonProps> = (props: IFloatingButtonPr
|
|||
: <XIcon className="floating-btn" />;
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className={`transition-all ${props.className}`}>
|
||||
<div className={`transition-all flex flex-col gap-2 items-center ${buttonListClasses}`}>
|
||||
{ props.children }
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,6 @@ interface IHistoryProps {
|
|||
history: IHistoryState[]
|
||||
historyCurrentStep: number
|
||||
isOpen: boolean
|
||||
onClick: () => void
|
||||
jumpTo: (move: number) => void
|
||||
}
|
||||
|
||||
|
@ -46,12 +45,9 @@ export class History extends React.PureComponent<IHistoryProps> {
|
|||
states.reverse();
|
||||
|
||||
return (
|
||||
<div className={`fixed flex flex-col bg-slate-400 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<button className='close-button bg-slate-500 hover:bg-slate-700 justify-start' onClick={this.props.onClick}>
|
||||
× Close
|
||||
</button>
|
||||
<div className='bg-slate-600 sidebar-row'>
|
||||
History
|
||||
<div className={`fixed flex flex-col bg-slate-300 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<div className='bg-slate-600 font-bold sidebar-title'>
|
||||
Timeline
|
||||
</div>
|
||||
<div className='overflow-y-auto overflow-x-hidden text-slate-300 flex-grow divide-y divide-solid divide-slate-600'>
|
||||
{ states }
|
||||
|
|
|
@ -18,7 +18,7 @@ export class Properties extends React.PureComponent<IPropertiesProps> {
|
|||
.forEach((pair) => this.handleProperties(pair, groupInput));
|
||||
|
||||
return (
|
||||
<div className='p-3 bg-slate-500 h-3/5 overflow-y-auto'>
|
||||
<div className='p-3 bg-slate-200 h-3/5 overflow-y-auto'>
|
||||
{ groupInput }
|
||||
</div>
|
||||
);
|
||||
|
@ -33,12 +33,12 @@ export class Properties extends React.PureComponent<IPropertiesProps> {
|
|||
const isDisabled = key === 'id' || key === 'parentId'; // hardcoded
|
||||
groupInput.push(
|
||||
<div key={id} className='mt-4'>
|
||||
<label className='text-sm font-medium text-slate-200' htmlFor={id}>{key}</label>
|
||||
<label className='text-sm font-medium text-gray-800' htmlFor={id}>{key}</label>
|
||||
<input
|
||||
className='text-base font-medium transition-all text-slate-200 mt-1 block w-full px-3 py-2
|
||||
bg-slate-600 border-2 border-slate-600 rounded-lg shadow-sm placeholder-slate-400
|
||||
className='text-base font-medium transition-all text-gray-800 mt-1 block w-full px-3 py-2
|
||||
bg-white border-2 border-white rounded-lg placeholder-gray-800
|
||||
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
|
||||
disabled:bg-slate-700 disabled:text-slate-400 disabled:border-slate-700 disabled:shadow-none
|
||||
disabled:bg-slate-300 disabled:text-gray-500 disabled:border-slate-300 disabled:shadow-none
|
||||
'
|
||||
type={type}
|
||||
id={id}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { UncontrolledReactSVGPanZoom } from 'react-svg-pan-zoom';
|
|||
import { Container } from './Elements/Container';
|
||||
import { ContainerModel } from '../../Interfaces/ContainerModel';
|
||||
import { Selector } from './Elements/Selector';
|
||||
import { BAR_WIDTH } from '../Bar/Bar';
|
||||
|
||||
interface ISVGProps {
|
||||
width: number
|
||||
|
@ -12,8 +13,8 @@ interface ISVGProps {
|
|||
}
|
||||
|
||||
interface ISVGState {
|
||||
viewerWidth: number,
|
||||
viewerHeight: number,
|
||||
viewerWidth: number
|
||||
viewerHeight: number
|
||||
}
|
||||
|
||||
export class SVG extends React.PureComponent<ISVGProps> {
|
||||
|
@ -23,14 +24,14 @@ export class SVG extends React.PureComponent<ISVGProps> {
|
|||
constructor(props: ISVGProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
viewerWidth: window.innerWidth,
|
||||
viewerWidth: window.innerWidth - BAR_WIDTH,
|
||||
viewerHeight: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
resizeViewBox(): void {
|
||||
this.setState({
|
||||
viewerWidth: window.innerWidth,
|
||||
viewerWidth: window.innerWidth - BAR_WIDTH,
|
||||
viewerHeight: window.innerHeight
|
||||
});
|
||||
}
|
||||
|
@ -60,7 +61,7 @@ export class SVG extends React.PureComponent<ISVGProps> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div id={SVG.ID}>
|
||||
<div id={SVG.ID} className='ml-16'>
|
||||
<UncontrolledReactSVGPanZoom
|
||||
width={this.state.viewerWidth}
|
||||
height={this.state.viewerHeight}
|
||||
|
@ -69,7 +70,7 @@ export class SVG extends React.PureComponent<ISVGProps> {
|
|||
miniatureProps={{
|
||||
position: 'left',
|
||||
background: '#616264',
|
||||
width: window.innerWidth - 12,
|
||||
width: window.innerWidth - 12 - BAR_WIDTH,
|
||||
height: 120
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -5,29 +5,23 @@ import Sidebar from './Sidebar';
|
|||
|
||||
describe.concurrent('Sidebar', () => {
|
||||
it('Start default', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Sidebar
|
||||
componentOptions={[]}
|
||||
isOpen={true}
|
||||
onClick={handleClick}
|
||||
buttonOnClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const stuff = screen.queryByText(/stuff/i);
|
||||
const close = screen.getByText(/close/i);
|
||||
|
||||
expect(screen.getByText(/Components/i).classList.contains('left-0')).toBeDefined();
|
||||
expect(stuff).toBeNull();
|
||||
fireEvent.click(close);
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Start close', () => {
|
||||
render(<Sidebar
|
||||
componentOptions={[]}
|
||||
isOpen={false}
|
||||
onClick={() => {}}
|
||||
buttonOnClick={() => {}}
|
||||
/>);
|
||||
|
||||
|
@ -49,7 +43,6 @@ describe.concurrent('Sidebar', () => {
|
|||
}
|
||||
]}
|
||||
isOpen={true}
|
||||
onClick={() => {}}
|
||||
buttonOnClick={handleButtonClick}
|
||||
/>);
|
||||
const stuff = screen.getByText(/stuff/i);
|
||||
|
|
|
@ -1,32 +1,39 @@
|
|||
import * as React from 'react';
|
||||
import { AvailableContainer } from '../../Interfaces/AvailableContainer';
|
||||
import { truncateString } from '../../utils/stringtools';
|
||||
|
||||
interface ISidebarProps {
|
||||
componentOptions: AvailableContainer[]
|
||||
isOpen: boolean
|
||||
onClick: () => void
|
||||
buttonOnClick: (type: string) => void
|
||||
}
|
||||
|
||||
export default class Sidebar extends React.PureComponent<ISidebarProps> {
|
||||
public render(): JSX.Element {
|
||||
const listElements = this.props.componentOptions.map(componentOption =>
|
||||
<button className='hover:bg-blue-600 transition-all sidebar-row' key={componentOption.Type} onClick={() => this.props.buttonOnClick(componentOption.Type)}>
|
||||
{componentOption.Type}
|
||||
<button
|
||||
className='justify-center transition-all sidebar-component'
|
||||
key={componentOption.Type}
|
||||
title={componentOption.Type}
|
||||
onClick={() => this.props.buttonOnClick(componentOption.Type)}
|
||||
>
|
||||
{truncateString(componentOption.Type, 5)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const isOpenClasses = this.props.isOpen ? 'left-0' : '-left-64';
|
||||
const isOpenClasses = this.props.isOpen ? 'left-16' : '-left-64';
|
||||
return (
|
||||
<div className={`fixed bg-blue-500 dark:bg-blue-500 text-white transition-all h-screen w-64 overflow-y-auto z-20 ${isOpenClasses}`}>
|
||||
<button className='close-button hover:bg-blue-600 justify-end' onClick={this.props.onClick}>
|
||||
Close ×
|
||||
</button>
|
||||
<div className='bg-blue-400 sidebar-row'>
|
||||
<div className={`fixed z-10 bg-slate-200
|
||||
text-gray-700 transition-all h-screen w-64
|
||||
overflow-y-auto ${isOpenClasses}`}>
|
||||
<div className='bg-slate-100 sidebar-title'>
|
||||
Components
|
||||
</div>
|
||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-2
|
||||
m-2 md:text-xs font-bold'>
|
||||
{listElements}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { ContainerModel } from '../../Interfaces/ContainerModel';
|
|||
import { IHistoryState } from '../../App';
|
||||
import { PhotographIcon, UploadIcon } from '@heroicons/react/outline';
|
||||
import FloatingButton from '../FloatingButton/FloatingButton';
|
||||
import { Bar } from '../Bar/Bar';
|
||||
|
||||
interface IUIProps {
|
||||
current: IHistoryState
|
||||
|
@ -58,7 +59,7 @@ export class UI extends React.PureComponent<IUIProps, IUIState> {
|
|||
/**
|
||||
* Toggle the elements
|
||||
*/
|
||||
public ToggleHistory(): void {
|
||||
public ToggleTimeline(): void {
|
||||
this.setState({
|
||||
isHistoryOpen: !this.state.isHistoryOpen
|
||||
});
|
||||
|
@ -75,47 +76,34 @@ export class UI extends React.PureComponent<IUIProps, IUIState> {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Bar
|
||||
isSidebarOpen={this.state.isSidebarOpen}
|
||||
isElementsSidebarOpen={this.state.isElementsSidebarOpen}
|
||||
isHistoryOpen={this.state.isHistoryOpen}
|
||||
ToggleElementsSidebar={() => this.ToggleElementsSidebar()}
|
||||
ToggleSidebar={() => this.ToggleSidebar()}
|
||||
ToggleTimeline={() => this.ToggleTimeline()}
|
||||
/>
|
||||
|
||||
<Sidebar
|
||||
componentOptions={this.props.AvailableContainers}
|
||||
isOpen={this.state.isSidebarOpen}
|
||||
onClick={() => this.ToggleSidebar()}
|
||||
buttonOnClick={(type: string) => this.props.AddContainer(type)}
|
||||
/>
|
||||
<button
|
||||
className='fixed z-10 top-4 left-4 text-lg bg-blue-200 hover:bg-blue-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
|
||||
onClick={() => this.ToggleSidebar()}
|
||||
>
|
||||
☰ Components
|
||||
</button>
|
||||
|
||||
<ElementsSidebar
|
||||
MainContainer={this.props.current.MainContainer}
|
||||
SelectedContainer={this.props.current.SelectedContainer}
|
||||
isOpen={this.state.isElementsSidebarOpen}
|
||||
isHistoryOpen={this.state.isHistoryOpen}
|
||||
onClick={() => this.ToggleElementsSidebar()}
|
||||
onPropertyChange={this.props.OnPropertyChange}
|
||||
selectContainer={this.props.SelectContainer}
|
||||
/>
|
||||
<button
|
||||
className='fixed z-10 top-4 right-12 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
|
||||
onClick={() => this.ToggleElementsSidebar()}
|
||||
>
|
||||
☰ Elements
|
||||
</button>
|
||||
|
||||
<History
|
||||
history={this.props.history}
|
||||
historyCurrentStep={this.props.historyCurrentStep}
|
||||
isOpen={this.state.isHistoryOpen}
|
||||
onClick={() => this.ToggleHistory()}
|
||||
jumpTo={this.props.LoadState}
|
||||
/>
|
||||
<button
|
||||
className='fixed z-10 top-4 right-72 text-lg bg-slate-200 hover:bg-slate-300 transition-all drop-shadow-md hover:drop-shadow-lg py-2 px-3 rounded-lg'
|
||||
onClick={() => this.ToggleHistory()}>
|
||||
☰ History
|
||||
</button>
|
||||
|
||||
<FloatingButton className={`fixed z-10 flex flex-col gap-2 items-center bottom-40 ${buttonRightOffsetClasses}`}>
|
||||
<button
|
||||
|
@ -123,14 +111,14 @@ export class UI extends React.PureComponent<IUIProps, IUIState> {
|
|||
title='Export as JSON'
|
||||
onClick={this.props.SaveEditorAsJSON}
|
||||
>
|
||||
<UploadIcon className="h-full w-full text-white align-middle items-center justify-center" />
|
||||
<UploadIcon className="heroicon text-white" />
|
||||
</button>
|
||||
<button
|
||||
className={'transition-all w-10 h-10 p-2 align-middle items-center justify-center rounded-full bg-blue-500 hover:bg-blue-800'}
|
||||
title='Export as SVG'
|
||||
onClick={this.props.SaveEditorAsSVG}
|
||||
>
|
||||
<PhotographIcon className="h-full w-full text-white align-middle items-center justify-center" />
|
||||
<PhotographIcon className="heroicon text-white" />
|
||||
</button>
|
||||
</FloatingButton>
|
||||
</>
|
||||
|
|
BIN
src/assets/fonts/RobotoFlex-Regular.ttf
Normal file
BIN
src/assets/fonts/RobotoFlex-Regular.ttf
Normal file
Binary file not shown.
|
@ -3,19 +3,45 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.sidebar-row {
|
||||
@apply p-6 w-full
|
||||
.sidebar-title {
|
||||
@apply p-6 font-bold
|
||||
}
|
||||
|
||||
.sidebar-component {
|
||||
@apply transition-all px-2 py-6 text-sm rounded-lg bg-slate-300/60 hover:bg-slate-300
|
||||
}
|
||||
|
||||
.elements-sidebar-row {
|
||||
@apply pl-6 pr-6 pt-2 pb-2 w-full
|
||||
}
|
||||
|
||||
.close-button {
|
||||
@apply transition-all w-full h-auto p-4 flex
|
||||
}
|
||||
|
||||
.mainmenu-btn {
|
||||
@apply transition-all bg-blue-100 hover:bg-blue-200 text-blue-700 text-lg font-semibold p-8 rounded-lg
|
||||
}
|
||||
|
||||
.floating-btn {
|
||||
@apply h-full w-full text-white align-middle items-center justify-center
|
||||
}
|
||||
|
||||
.bar-btn {
|
||||
@apply h-16 w-full p-3 bg-slate-100 hover:bg-slate-200
|
||||
transition-all text-gray-700 hover:text-gray-600
|
||||
}
|
||||
|
||||
.heroicon {
|
||||
@apply h-full w-full align-middle items-center justify-center
|
||||
}
|
||||
|
||||
.sidebar-tooltip {
|
||||
@apply absolute w-auto p-2 m-2 min-w-max left-14
|
||||
rounded-md shadow-md
|
||||
text-gray-800 bg-slate-100
|
||||
dark:text-white dark:bg-gray-800
|
||||
text-xs font-bold
|
||||
transition-all duration-100 scale-0 origin-left;
|
||||
}
|
||||
}
|
6
src/utils/stringtools.ts
Normal file
6
src/utils/stringtools.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function truncateString(str: string, num: number): string {
|
||||
if (str.length <= num) {
|
||||
return str;
|
||||
}
|
||||
return `${str.slice(0, num)}...`;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue