arch: move web folder into api & move api to top level
This commit is contained in:
80
frontend/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
80
frontend/src/shared/components/PopupMenu/LabelEditor.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Checkmark } from 'shared/icons';
|
||||
import { SaveButton, DeleteButton, LabelBox, EditLabelForm, FieldLabel, FieldName } from './Styles';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const WhiteCheckmark = styled(Checkmark)`
|
||||
fill: rgba(${props => props.theme.colors.text.secondary});
|
||||
`;
|
||||
type Props = {
|
||||
labelColors: Array<LabelColor>;
|
||||
label: ProjectLabel | null;
|
||||
onLabelEdit: (labelId: string | null, labelName: string, labelColor: LabelColor) => void;
|
||||
onLabelDelete?: (labelId: string) => void;
|
||||
};
|
||||
|
||||
const LabelManager = ({ labelColors, label, onLabelEdit, onLabelDelete }: Props) => {
|
||||
const $fieldName = useRef<HTMLInputElement>(null);
|
||||
const [currentLabel, setCurrentLabel] = useState(label ? label.name : '');
|
||||
const [currentColor, setCurrentColor] = useState<LabelColor | null>(label ? label.labelColor : null);
|
||||
|
||||
useEffect(() => {
|
||||
if ($fieldName.current) {
|
||||
$fieldName.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EditLabelForm>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<FieldName
|
||||
ref={$fieldName}
|
||||
id="labelName"
|
||||
type="text"
|
||||
name="name"
|
||||
onChange={e => {
|
||||
setCurrentLabel(e.currentTarget.value);
|
||||
}}
|
||||
value={currentLabel ?? ''}
|
||||
/>
|
||||
<FieldLabel>Select a color</FieldLabel>
|
||||
<div>
|
||||
{labelColors.map((labelColor: LabelColor) => (
|
||||
<LabelBox
|
||||
key={labelColor.id}
|
||||
color={labelColor.colorHex}
|
||||
onClick={() => {
|
||||
setCurrentColor(labelColor);
|
||||
}}
|
||||
>
|
||||
{currentColor && labelColor.id === currentColor.id && <WhiteCheckmark width={12} height={12} />}
|
||||
</LabelBox>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<SaveButton
|
||||
value="Save"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
console.log(currentColor);
|
||||
if (currentColor) {
|
||||
onLabelEdit(label ? label.id : null, currentLabel ?? '', currentColor);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{label && onLabelDelete && (
|
||||
<DeleteButton
|
||||
value="Delete"
|
||||
type="submit"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onLabelDelete(label.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EditLabelForm>
|
||||
);
|
||||
};
|
||||
export default LabelManager;
|
93
frontend/src/shared/components/PopupMenu/LabelManager.tsx
Normal file
93
frontend/src/shared/components/PopupMenu/LabelManager.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Pencil, Checkmark } from 'shared/icons';
|
||||
|
||||
import {
|
||||
LabelSearch,
|
||||
ActiveIcon,
|
||||
Labels,
|
||||
Label,
|
||||
CardLabel,
|
||||
Section,
|
||||
SectionTitle,
|
||||
LabelIcon,
|
||||
CreateLabelButton,
|
||||
} from './Styles';
|
||||
|
||||
type Props = {
|
||||
labels?: Array<ProjectLabel>;
|
||||
taskLabels?: Array<TaskLabel>;
|
||||
onLabelToggle: (labelId: string) => void;
|
||||
onLabelEdit: (labelId: string) => void;
|
||||
onLabelCreate: () => void;
|
||||
};
|
||||
|
||||
const LabelManager: React.FC<Props> = ({ labels, taskLabels, onLabelToggle, onLabelEdit, onLabelCreate }) => {
|
||||
const $fieldName = useRef<HTMLInputElement>(null);
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const [currentSearch, setCurrentSearch] = useState('');
|
||||
useEffect(() => {
|
||||
if ($fieldName.current) {
|
||||
$fieldName.current.focus();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<LabelSearch
|
||||
type="text"
|
||||
ref={$fieldName}
|
||||
placeholder="search labels..."
|
||||
onChange={e => {
|
||||
setCurrentSearch(e.currentTarget.value);
|
||||
}}
|
||||
value={currentSearch}
|
||||
/>
|
||||
<Section>
|
||||
<SectionTitle>Labels</SectionTitle>
|
||||
<Labels>
|
||||
{labels &&
|
||||
labels
|
||||
.filter(
|
||||
label =>
|
||||
currentSearch === '' ||
|
||||
(label.name && label.name.toLowerCase().startsWith(currentSearch.toLowerCase())),
|
||||
)
|
||||
.map(label => (
|
||||
<Label key={label.id}>
|
||||
<LabelIcon
|
||||
onClick={() => {
|
||||
onLabelEdit(label.id);
|
||||
}}
|
||||
>
|
||||
<Pencil width={16} height={16} />
|
||||
</LabelIcon>
|
||||
<CardLabel
|
||||
key={label.id}
|
||||
color={label.labelColor.colorHex}
|
||||
active={currentLabel === label.id}
|
||||
onMouseEnter={() => {
|
||||
setCurrentLabel(label.id);
|
||||
}}
|
||||
onClick={() => onLabelToggle(label.id)}
|
||||
>
|
||||
{label.name}
|
||||
{taskLabels && taskLabels.find(t => t.projectLabel.id === label.id) && (
|
||||
<ActiveIcon>
|
||||
<Checkmark width={16} height={16} />
|
||||
</ActiveIcon>
|
||||
)}
|
||||
</CardLabel>
|
||||
</Label>
|
||||
))}
|
||||
</Labels>
|
||||
<CreateLabelButton
|
||||
onClick={() => {
|
||||
onLabelCreate();
|
||||
}}
|
||||
>
|
||||
Create a new label
|
||||
</CreateLabelButton>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default LabelManager;
|
382
frontend/src/shared/components/PopupMenu/PopupMenu.stories.tsx
Normal file
382
frontend/src/shared/components/PopupMenu/PopupMenu.stories.tsx
Normal file
@ -0,0 +1,382 @@
|
||||
import React, { useState, useRef, createRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import LabelColors from 'shared/constants/labelColors';
|
||||
import LabelManager from 'shared/components/PopupMenu/LabelManager';
|
||||
import LabelEditor from 'shared/components/PopupMenu/LabelEditor';
|
||||
import ListActions from 'shared/components/ListActions';
|
||||
import MemberManager from 'shared/components/MemberManager';
|
||||
import DueDateManager from 'shared/components/DueDateManager';
|
||||
import MiniProfile from 'shared/components/MiniProfile';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import produce from 'immer';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
|
||||
import PopupMenu, { PopupProvider, usePopup, Popup } from '.';
|
||||
|
||||
export default {
|
||||
component: PopupMenu,
|
||||
title: 'PopupMenu',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const labelData: Array<ProjectLabel> = [
|
||||
{
|
||||
id: 'development',
|
||||
name: 'Development',
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: '1',
|
||||
colorHex: LabelColors.BLUE,
|
||||
name: 'blue',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const OpenLabelBtn = styled.span``;
|
||||
|
||||
type TabProps = {
|
||||
tab: number;
|
||||
};
|
||||
|
||||
const LabelManagerEditor = () => {
|
||||
const [labels, setLabels] = useState(labelData);
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const { setTab } = usePopup();
|
||||
return (
|
||||
<>
|
||||
<Popup title="Labels" tab={0} onClose={action('on close')}>
|
||||
<LabelManager
|
||||
labels={labels}
|
||||
onLabelCreate={() => {
|
||||
setTab(2);
|
||||
}}
|
||||
onLabelEdit={labelId => {
|
||||
setCurrentLabel(labelId);
|
||||
setTab(1);
|
||||
}}
|
||||
onLabelToggle={labelId => {
|
||||
setLabels(
|
||||
produce(labels, draftState => {
|
||||
const idx = labels.findIndex(label => label.id === labelId);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = { ...draftState[idx] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={action('on close')} title="Edit label" tab={1}>
|
||||
<LabelEditor
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
label={labels.find(label => label.id === currentLabel) ?? null}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
setLabels(
|
||||
produce(labels, draftState => {
|
||||
const idx = labels.findIndex(label => label.id === currentLabel);
|
||||
if (idx !== -1) {
|
||||
draftState[idx] = {
|
||||
...draftState[idx],
|
||||
name,
|
||||
labelColor: {
|
||||
...draftState[idx].labelColor,
|
||||
name: color.name ?? '',
|
||||
colorHex: color.colorHex,
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
<Popup onClose={action('on close')} title="Create new label" tab={2}>
|
||||
<LabelEditor
|
||||
label={null}
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
onLabelEdit={(_labelId, name, color) => {
|
||||
setLabels([
|
||||
...labels,
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
createdDate: new Date().toString(),
|
||||
labelColor: {
|
||||
id: color.id,
|
||||
colorHex: color.colorHex,
|
||||
name: color.name ?? '',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
setTab(0);
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenLabelsButton = () => {
|
||||
const $buttonRef = createRef<HTMLButtonElement>();
|
||||
const [currentLabel, setCurrentLabel] = useState('');
|
||||
const [labels, setLabels] = useState(labelData);
|
||||
const { showPopup, setTab } = usePopup();
|
||||
console.log(labels);
|
||||
return (
|
||||
<OpenLabelBtn
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
showPopup($buttonRef, <LabelManagerEditor />);
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</OpenLabelBtn>
|
||||
);
|
||||
};
|
||||
|
||||
export const LabelsPopup = () => {
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
return (
|
||||
<PopupProvider>
|
||||
<OpenLabelsButton />
|
||||
</PopupProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const LabelsLabelEditor = () => {
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{isPopupOpen && (
|
||||
<PopupMenu
|
||||
onPrevious={action('on previous')}
|
||||
title="Change Label"
|
||||
top={10}
|
||||
onClose={() => setPopupOpen(false)}
|
||||
left={10}
|
||||
>
|
||||
<LabelEditor
|
||||
label={labelData[0]}
|
||||
onLabelEdit={action('label edit')}
|
||||
labelColors={[{ id: '1', colorHex: '#c2c6dc', position: 1, name: 'gray' }]}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<button type="submit" onClick={() => setPopupOpen(true)}>
|
||||
Open
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const initalState = { left: 0, top: 0, isOpen: false };
|
||||
|
||||
export const ListActionsPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu
|
||||
title="List Actions"
|
||||
top={popupData.top}
|
||||
onClose={() => setPopupData(initalState)}
|
||||
left={popupData.left}
|
||||
>
|
||||
<ListActions taskGroupID="1" onArchiveTaskGroup={action('archive task group')} />
|
||||
</PopupMenu>
|
||||
)}
|
||||
<button
|
||||
ref={$buttonRef}
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemberManagerPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu title="Members" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
|
||||
<MemberManager
|
||||
availableMembers={[
|
||||
{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
},
|
||||
]}
|
||||
activeMembers={[]}
|
||||
onMemberChange={action('member change')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DueDateManagerPopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu title="Due Date" top={popupData.top} onClose={() => setPopupData(initalState)} left={popupData.left}>
|
||||
<DueDateManager
|
||||
onRemoveDueDate={action('remove due date')}
|
||||
task={{
|
||||
id: '1',
|
||||
taskGroup: { name: 'General', id: '1', position: 1 },
|
||||
name: 'Hello, world',
|
||||
position: 1,
|
||||
labels: [
|
||||
{
|
||||
id: 'soft-skills',
|
||||
assignedDate: new Date().toString(),
|
||||
projectLabel: {
|
||||
createdDate: new Date().toString(),
|
||||
id: 'label-soft-skills',
|
||||
name: 'Soft Skills',
|
||||
labelColor: {
|
||||
id: '1',
|
||||
name: 'white',
|
||||
colorHex: '#fff',
|
||||
position: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
description: 'hello!',
|
||||
assigned: [
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancel={action('cancel')}
|
||||
onDueDateChange={action('due date change')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: '60px',
|
||||
textAlign: 'center',
|
||||
margin: '25px auto',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniProfilePopup = () => {
|
||||
const $buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [popupData, setPopupData] = useState(initalState);
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
{popupData.isOpen && (
|
||||
<PopupMenu
|
||||
noHeader
|
||||
title="Due Date"
|
||||
top={popupData.top}
|
||||
onClose={() => setPopupData(initalState)}
|
||||
left={popupData.left}
|
||||
>
|
||||
<MiniProfile
|
||||
user={{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
username: 'jordanthedev',
|
||||
profileIcon: { url: null, bgColor: '#000', initials: 'JK' },
|
||||
}}
|
||||
bio="Stuff and things"
|
||||
onRemoveFromTask={action('mini profile')}
|
||||
/>
|
||||
</PopupMenu>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
width: '60px',
|
||||
textAlign: 'center',
|
||||
margin: '25px auto',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
background: '#f00',
|
||||
}}
|
||||
ref={$buttonRef}
|
||||
onClick={() => {
|
||||
if ($buttonRef && $buttonRef.current) {
|
||||
const pos = $buttonRef.current.getBoundingClientRect();
|
||||
setPopupData({
|
||||
isOpen: true,
|
||||
left: pos.left,
|
||||
top: pos.top + pos.height + 10,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
374
frontend/src/shared/components/PopupMenu/Styles.ts
Normal file
374
frontend/src/shared/components/PopupMenu/Styles.ts
Normal file
@ -0,0 +1,374 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Container = styled.div<{
|
||||
invertY: boolean;
|
||||
invert: boolean;
|
||||
top: number;
|
||||
left: number;
|
||||
ref: any;
|
||||
width: number | string;
|
||||
}>`
|
||||
left: ${props => props.left}px;
|
||||
top: ${props => props.top}px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: ${props => props.width}px;
|
||||
padding-top: 10px;
|
||||
height: auto;
|
||||
z-index: 40000;
|
||||
${props =>
|
||||
props.invert &&
|
||||
css`
|
||||
transform: translate(-100%);
|
||||
`}
|
||||
${props =>
|
||||
props.invertY &&
|
||||
css`
|
||||
top: auto;
|
||||
padding-top: 0;
|
||||
padding-bottom: 10px;
|
||||
bottom: ${props.top}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
padding: 5px;
|
||||
padding-top: 8px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 5px 25px 0 rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
||||
color: #c2c6dc;
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-color: #414561;
|
||||
`;
|
||||
|
||||
export const Header = styled.div`
|
||||
height: 40px;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const HeaderTitle = styled.span`
|
||||
box-sizing: border-box;
|
||||
color: #c2c6dc;
|
||||
display: block;
|
||||
border-bottom: 1px solid #414561;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
padding: 0 32px;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
|
||||
height: 40px;
|
||||
line-height: 18px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const Content = styled.div`
|
||||
max-height: 632px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
export const LabelSearch = styled.input`
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
transition-property: background-color, border-color, box-shadow;
|
||||
transition-duration: 85ms;
|
||||
transition-timing-function: ease;
|
||||
margin: 4px 0 12px;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'Droid Sans';
|
||||
font-weight: 400;
|
||||
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
border-color: #414561;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Section = styled.div`
|
||||
margin-top: 12px;
|
||||
`;
|
||||
|
||||
export const SectionTitle = styled.h4`
|
||||
color: #c2c6dc;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 16px;
|
||||
margin-top: 16px;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export const Labels = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
export const Label = styled.li`
|
||||
padding-right: 36px;
|
||||
position: relative;
|
||||
`;
|
||||
export const CardLabel = styled.span<{ active: boolean; color: string }>`
|
||||
${props =>
|
||||
props.active &&
|
||||
css`
|
||||
margin-left: 4px;
|
||||
box-shadow: -8px 0 ${mixin.darken(props.color, 0.12)};
|
||||
border-radius: 3px;
|
||||
`}
|
||||
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
min-height: 20px;
|
||||
padding: 6px 12px;
|
||||
position: relative;
|
||||
transition: padding 85ms, margin 85ms, box-shadow 85ms;
|
||||
background-color: ${props => props.color};
|
||||
color: #fff;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-height: 31px;
|
||||
`;
|
||||
|
||||
export const CloseButton = styled.div`
|
||||
padding: 18px 18px 14px 12px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const LabelIcon = styled.div`
|
||||
border-radius: 3px;
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActiveIcon = styled.div`
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0.85;
|
||||
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
export const EditLabelForm = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const FieldLabel = styled.label`
|
||||
font-weight: 700;
|
||||
color: #5e6c84;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export const FieldName = styled.input`
|
||||
margin: 4px 0 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #262c49;
|
||||
outline: none;
|
||||
color: #c2c6dc;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
&:focus {
|
||||
box-shadow: rgb(115, 103, 240) 0px 0px 0px 1px;
|
||||
background: ${mixin.darken('#262c49', 0.15)};
|
||||
}
|
||||
`;
|
||||
|
||||
export const LabelBox = styled.span<{ color: string }>`
|
||||
float: left;
|
||||
height: 32px;
|
||||
margin: 0 8px 8px 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
|
||||
cursor: pointer;
|
||||
background-color: ${props => props.color};
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SaveButton = styled.input`
|
||||
background: rgb(115, 103, 240);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-right: 4px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
export const DeleteButton = styled.input`
|
||||
float: right;
|
||||
outline: none;
|
||||
border: none;
|
||||
line-height: 20px;
|
||||
padding: 6px 12px;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
margin: 0 0 0 8px;
|
||||
|
||||
border-radius: 3px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: #414561;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgb(115, 103, 240);
|
||||
border-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CreateLabelButton = styled.button`
|
||||
outline: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 12px;
|
||||
background-color: none;
|
||||
text-align: center;
|
||||
color: #c2c6dc;
|
||||
margin: 8px 4px 0 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgb(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
export const PreviousButton = styled.div`
|
||||
padding: 18px 18px 14px 12px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const ContainerDiamond = styled.div<{ invert: boolean; invertY: boolean }>`
|
||||
${props => (props.invert ? 'right: 10px; ' : 'left: 15px;')}
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
${props =>
|
||||
props.invertY
|
||||
? css`
|
||||
bottom: 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
`
|
||||
: css`
|
||||
top: 10px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
`}
|
||||
transform: rotate(45deg) translate(-7px);
|
||||
z-index: 10;
|
||||
|
||||
background: #262c49;
|
||||
border-color: #414561;
|
||||
`;
|
258
frontend/src/shared/components/PopupMenu/index.tsx
Normal file
258
frontend/src/shared/components/PopupMenu/index.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import React, { useRef, createContext, RefObject, useState, useContext, useEffect } from 'react';
|
||||
import { Cross, AngleLeft } from 'shared/icons';
|
||||
import useOnOutsideClick from 'shared/hooks/onOutsideClick';
|
||||
import { createPortal } from 'react-dom';
|
||||
import produce from 'immer';
|
||||
import {
|
||||
Container,
|
||||
ContainerDiamond,
|
||||
Header,
|
||||
HeaderTitle,
|
||||
Content,
|
||||
CloseButton,
|
||||
PreviousButton,
|
||||
Wrapper,
|
||||
} from './Styles';
|
||||
|
||||
type PopupContextState = {
|
||||
show: (target: RefObject<HTMLElement>, content: JSX.Element, width?: string | number) => void;
|
||||
setTab: (newTab: number, width?: number | string) => void;
|
||||
getCurrentTab: () => number;
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
type PopupProps = {
|
||||
title: string | null;
|
||||
onClose?: () => void;
|
||||
tab: number;
|
||||
};
|
||||
|
||||
type PopupContainerProps = {
|
||||
top: number;
|
||||
left: number;
|
||||
invert: boolean;
|
||||
invertY: boolean;
|
||||
onClose: () => void;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const PopupContainer: React.FC<PopupContainerProps> = ({ width, top, left, onClose, children, invert, invertY }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
const [currentTop, setCurrentTop] = useState(top);
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
return (
|
||||
<Container width={width ?? 316} left={left} top={currentTop} ref={$containerRef} invert={invert} invertY={invertY}>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
PopupContainer.defaultProps = {
|
||||
width: 316,
|
||||
};
|
||||
|
||||
const PopupContext = createContext<PopupContextState>({
|
||||
show: () => {},
|
||||
setTab: () => {},
|
||||
getCurrentTab: () => 0,
|
||||
hide: () => {},
|
||||
});
|
||||
|
||||
export const usePopup = () => {
|
||||
const ctx = useContext<PopupContextState>(PopupContext);
|
||||
return { showPopup: ctx.show, setTab: ctx.setTab, getCurrentTab: ctx.getCurrentTab, hidePopup: ctx.hide };
|
||||
};
|
||||
|
||||
type PopupState = {
|
||||
isOpen: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
invertY: boolean;
|
||||
invert: boolean;
|
||||
currentTab: number;
|
||||
previousTab: number;
|
||||
content: JSX.Element | null;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const { Provider, Consumer } = PopupContext;
|
||||
|
||||
const canUseDOM = !!(typeof window !== 'undefined' && window.document && window.document.createElement);
|
||||
|
||||
const defaultState = {
|
||||
isOpen: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
invert: false,
|
||||
invertY: false,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
};
|
||||
|
||||
export const PopupProvider: React.FC = ({ children }) => {
|
||||
const [currentState, setState] = useState<PopupState>(defaultState);
|
||||
const show = (target: RefObject<HTMLElement>, content: JSX.Element, width?: number | string) => {
|
||||
if (target && target.current) {
|
||||
const bounds = target.current.getBoundingClientRect();
|
||||
let top = bounds.top + bounds.height;
|
||||
let invertY = false;
|
||||
if (window.innerHeight / 2 < top) {
|
||||
top = window.innerHeight - bounds.top;
|
||||
invertY = true;
|
||||
}
|
||||
if (bounds.left + 304 + 30 > window.innerWidth) {
|
||||
setState({
|
||||
isOpen: true,
|
||||
left: bounds.left + bounds.width,
|
||||
top,
|
||||
invertY,
|
||||
invert: true,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
});
|
||||
} else {
|
||||
setState({
|
||||
isOpen: true,
|
||||
left: bounds.left,
|
||||
top,
|
||||
invert: false,
|
||||
invertY,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content,
|
||||
width: width ?? 316,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const hide = () => {
|
||||
setState({
|
||||
isOpen: false,
|
||||
left: 0,
|
||||
top: 0,
|
||||
invert: true,
|
||||
invertY: false,
|
||||
currentTab: 0,
|
||||
previousTab: 0,
|
||||
content: null,
|
||||
});
|
||||
};
|
||||
const portalTarget = canUseDOM ? document.body : null; // appease flow
|
||||
|
||||
const setTab = (newTab: number, width?: number | string) => {
|
||||
const newWidth = width ?? currentState.width;
|
||||
setState((prevState: PopupState) => {
|
||||
return {
|
||||
...prevState,
|
||||
previousTab: currentState.currentTab,
|
||||
currentTab: newTab,
|
||||
width: newWidth,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentTab = () => {
|
||||
return currentState.currentTab;
|
||||
};
|
||||
|
||||
return (
|
||||
<Provider value={{ hide, show, setTab, getCurrentTab }}>
|
||||
{portalTarget &&
|
||||
currentState.isOpen &&
|
||||
createPortal(
|
||||
<PopupContainer
|
||||
invertY={currentState.invertY}
|
||||
invert={currentState.invert}
|
||||
top={currentState.top}
|
||||
left={currentState.left}
|
||||
onClose={() => setState(defaultState)}
|
||||
width={currentState.width ?? 316}
|
||||
>
|
||||
{currentState.content}
|
||||
<ContainerDiamond invertY={currentState.invertY} invert={currentState.invert} />
|
||||
</PopupContainer>,
|
||||
portalTarget,
|
||||
)}
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: string | null;
|
||||
top: number;
|
||||
left: number;
|
||||
onClose: () => void;
|
||||
onPrevious?: () => void | null;
|
||||
noHeader?: boolean | null;
|
||||
width?: string | number;
|
||||
};
|
||||
|
||||
const PopupMenu: React.FC<Props> = ({ width, title, top, left, onClose, noHeader, children, onPrevious }) => {
|
||||
const $containerRef = useRef<HTMLDivElement>(null);
|
||||
useOnOutsideClick($containerRef, true, onClose, null);
|
||||
|
||||
return (
|
||||
<Container invertY={false} width={width ?? 316} invert={false} left={left} top={top} ref={$containerRef}>
|
||||
<Wrapper>
|
||||
{onPrevious && (
|
||||
<PreviousButton onClick={onPrevious}>
|
||||
<AngleLeft color="#c2c6dc" />
|
||||
</PreviousButton>
|
||||
)}
|
||||
{noHeader ? (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
) : (
|
||||
<Header>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
</Header>
|
||||
)}
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export const Popup: React.FC<PopupProps> = ({ title, onClose, tab, children }) => {
|
||||
const { getCurrentTab, setTab } = usePopup();
|
||||
if (getCurrentTab() !== tab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper>
|
||||
{tab > 0 && (
|
||||
<PreviousButton
|
||||
onClick={() => {
|
||||
setTab(0);
|
||||
}}
|
||||
>
|
||||
<AngleLeft color="#c2c6dc" />
|
||||
</PreviousButton>
|
||||
)}
|
||||
{title && (
|
||||
<Header>
|
||||
<HeaderTitle>{title}</HeaderTitle>
|
||||
</Header>
|
||||
)}
|
||||
{onClose && (
|
||||
<CloseButton onClick={() => onClose()}>
|
||||
<Cross width={16} height={16} />
|
||||
</CloseButton>
|
||||
)}
|
||||
<Content>{children}</Content>
|
||||
</Wrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupMenu;
|
Reference in New Issue
Block a user