feature: various additions
This commit is contained in:
@ -7,3 +7,7 @@ indent_size = 2
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
|
3
web/Makefile
Normal file
3
web/Makefile
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
start:
|
||||
yarn start
|
@ -18,7 +18,9 @@
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/color": "^3.0.1",
|
||||
"@types/date-fns": "^2.6.0",
|
||||
"@types/jest": "^24.0.0",
|
||||
"@types/jwt-decode": "^2.2.1",
|
||||
"@types/lodash": "^4.14.149",
|
||||
@ -32,6 +34,8 @@
|
||||
"@types/react-select": "^3.0.13",
|
||||
"@types/styled-components": "^5.0.0",
|
||||
"@welldone-software/why-did-you-render": "^4.2.2",
|
||||
"ag-grid-community": "^23.2.0",
|
||||
"ag-grid-react": "^23.2.0",
|
||||
"apollo-cache-inmemory": "^1.6.5",
|
||||
"apollo-client": "^2.6.8",
|
||||
"apollo-link": "^1.2.13",
|
||||
@ -39,7 +43,10 @@
|
||||
"apollo-link-http": "^1.5.16",
|
||||
"apollo-link-state": "^0.4.2",
|
||||
"apollo-utilities": "^1.3.3",
|
||||
"axios": "^0.19.2",
|
||||
"axios-auth-refresh": "^2.2.7",
|
||||
"color": "^3.1.2",
|
||||
"date-fns": "^2.14.0",
|
||||
"graphql": "^15.0.0",
|
||||
"graphql-tag": "^2.10.3",
|
||||
"history": "^4.10.1",
|
||||
|
@ -6,7 +6,14 @@ import Dashboard from 'Dashboard';
|
||||
import Projects from 'Projects';
|
||||
import Project from 'Projects/Project';
|
||||
import Login from 'Auth';
|
||||
import Profile from 'Profile';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
background: #262c49;
|
||||
height: 100%;
|
||||
`;
|
||||
type RoutesProps = {
|
||||
history: H.History;
|
||||
};
|
||||
@ -14,9 +21,12 @@ type RoutesProps = {
|
||||
const Routes = ({ history }: RoutesProps) => (
|
||||
<Switch>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route exact path="/projects" component={Projects} />
|
||||
<Route path="/projects/:projectID" component={Project} />
|
||||
<MainContent>
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route exact path="/projects" component={Projects} />
|
||||
<Route path="/projects/:projectID" component={Project} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
</MainContent>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import TopNavbar from 'shared/components/TopNavbar';
|
||||
import DropdownMenu from 'shared/components/DropdownMenu';
|
||||
import DropdownMenu, { ProfileMenu } from 'shared/components/DropdownMenu';
|
||||
import ProjectSettings from 'shared/components/ProjectSettings';
|
||||
import { useHistory } from 'react-router';
|
||||
import UserIDContext from 'App/context';
|
||||
@ -14,21 +14,37 @@ type GlobalTopNavbarProps = {
|
||||
};
|
||||
const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ name, projectMembers, onSaveProjectName }) => {
|
||||
const { loading, data } = useMeQuery();
|
||||
const { showPopup } = usePopup();
|
||||
const { showPopup, hidePopup } = usePopup();
|
||||
const history = useHistory();
|
||||
const { userID, setUserID } = useContext(UserIDContext);
|
||||
const [menu, setMenu] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
isOpen: false,
|
||||
});
|
||||
const onProfileClick = (bottom: number, right: number) => {
|
||||
setMenu({
|
||||
isOpen: !menu.isOpen,
|
||||
left: right,
|
||||
top: bottom,
|
||||
const onLogout = () => {
|
||||
fetch('http://localhost:3333/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
history.replace('/login');
|
||||
setUserID(null);
|
||||
hidePopup();
|
||||
}
|
||||
});
|
||||
};
|
||||
const onProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
showPopup(
|
||||
$target,
|
||||
<Popup title={null} tab={0}>
|
||||
<ProfileMenu
|
||||
onLogout={onLogout}
|
||||
onProfile={() => {
|
||||
history.push('/profile');
|
||||
hidePopup();
|
||||
}}
|
||||
/>
|
||||
</Popup>,
|
||||
185,
|
||||
);
|
||||
};
|
||||
|
||||
const onOpenSettings = ($target: React.RefObject<HTMLElement>) => {
|
||||
showPopup(
|
||||
@ -40,18 +56,6 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ name, projectMembers,
|
||||
);
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
fetch('http://localhost:3333/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}).then(async x => {
|
||||
const { status } = x;
|
||||
if (status === 200) {
|
||||
history.replace('/login');
|
||||
setUserID(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (!userID) {
|
||||
return null;
|
||||
}
|
||||
@ -59,30 +63,13 @@ const GlobalTopNavbar: React.FC<GlobalTopNavbarProps> = ({ name, projectMembers,
|
||||
<>
|
||||
<TopNavbar
|
||||
projectName={name}
|
||||
bgColor={data ? data.me.profileIcon.bgColor ?? '#7367F0' : '#7367F0'}
|
||||
firstName={data ? data.me.firstName : ''}
|
||||
lastName={data ? data.me.lastName : ''}
|
||||
initials={!data ? '' : data.me.profileIcon.initials ?? ''}
|
||||
user={data ? data.me : null}
|
||||
onNotificationClick={() => {}}
|
||||
projectMembers={projectMembers}
|
||||
onProfileClick={onProfileClick}
|
||||
onSaveProjectName={onSaveProjectName}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
{menu.isOpen && (
|
||||
<DropdownMenu
|
||||
onCloseDropdown={() => {
|
||||
setMenu({
|
||||
top: 0,
|
||||
left: 0,
|
||||
isOpen: false,
|
||||
});
|
||||
}}
|
||||
onLogout={onLogout}
|
||||
left={menu.left}
|
||||
top={menu.top}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -13,12 +13,6 @@ import { PopupProvider } from 'shared/components/PopupMenu';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
background: #262c49;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userID, setUserID] = useState<string | null>(null);
|
||||
@ -54,9 +48,7 @@ const App = () => {
|
||||
) : (
|
||||
<>
|
||||
<Navbar />
|
||||
<MainContent>
|
||||
<Routes history={history} />
|
||||
</MainContent>
|
||||
<Routes history={history} />
|
||||
</>
|
||||
)}
|
||||
</Router>
|
||||
|
71
web/src/Profile/index.tsx
Normal file
71
web/src/Profile/index.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getAccessToken } from 'shared/utils/accessToken';
|
||||
import Navbar from 'App/Navbar';
|
||||
import Settings from 'shared/components/Settings';
|
||||
import UserIDContext from 'App/context';
|
||||
import { useMeQuery, useClearProfileAvatarMutation } from 'shared/generated/graphql';
|
||||
import axios from 'axios';
|
||||
|
||||
const MainContent = styled.div`
|
||||
padding: 0 0 50px 80px;
|
||||
height: 100%;
|
||||
background: #262c49;
|
||||
`;
|
||||
|
||||
const Projects = () => {
|
||||
const $fileUpload = useRef<HTMLInputElement>(null);
|
||||
const [clearProfileAvatar] = useClearProfileAvatarMutation();
|
||||
const { loading, data, refetch } = useMeQuery();
|
||||
useEffect(() => {
|
||||
document.title = 'Profile | Citadel';
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
style={{ display: 'none' }}
|
||||
ref={$fileUpload}
|
||||
onChange={e => {
|
||||
if (e.target.files) {
|
||||
console.log(e.target.files[0]);
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', e.target.files[0]);
|
||||
const accessToken = getAccessToken();
|
||||
axios
|
||||
.post('http://localhost:3333/users/me/avatar', fileData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.value = '';
|
||||
refetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<GlobalTopNavbar onSaveProjectName={() => {}} name={null} />
|
||||
{!loading && data && (
|
||||
<Settings
|
||||
profile={data.me.profileIcon}
|
||||
onProfileAvatarChange={() => {
|
||||
if ($fileUpload && $fileUpload.current) {
|
||||
$fileUpload.current.click();
|
||||
}
|
||||
}}
|
||||
onProfileAvatarRemove={() => {
|
||||
clearProfileAvatar();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useContext } from 'react';
|
||||
import React, { useState, useRef, useContext, useEffect } from 'react';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import styled from 'styled-components/macro';
|
||||
import { Bolt, ToggleOn, Tags } from 'shared/icons';
|
||||
@ -428,6 +428,11 @@ const Project = () => {
|
||||
const $labelsRef = useRef<HTMLDivElement>(null);
|
||||
const labelsRef = useRef<Array<ProjectLabel>>([]);
|
||||
const taskLabelsRef = useRef<Array<TaskLabel>>([]);
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
document.title = `${data.findProject.name} | Citadel`;
|
||||
}
|
||||
}, [data]);
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
@ -562,6 +567,7 @@ const Project = () => {
|
||||
</Popup>,
|
||||
);
|
||||
}}
|
||||
onChangeTaskGroupName={(taskGroupID, name) => {}}
|
||||
onQuickEditorOpen={onQuickEditorOpen}
|
||||
onExtraMenuOpen={(taskGroupID: string, $targetRef: any) => {
|
||||
showPopup(
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
import React, { useState, useContext, useEffect } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import GlobalTopNavbar from 'App/TopNavbar';
|
||||
import { useGetProjectsQuery, useCreateProjectMutation, GetProjectsDocument } from 'shared/generated/graphql';
|
||||
@ -28,6 +28,9 @@ const ProjectLink = styled(Link)``;
|
||||
|
||||
const Projects = () => {
|
||||
const { loading, data } = useGetProjectsQuery();
|
||||
useEffect(() => {
|
||||
document.title = 'Citadel';
|
||||
}, []);
|
||||
const [createProject] = useCreateProjectMutation({
|
||||
update: (client, newProject) => {
|
||||
const cacheData: any = client.readQuery({
|
||||
|
8
web/src/citadel.d.ts
vendored
8
web/src/citadel.d.ts
vendored
@ -18,8 +18,7 @@ type ContextMenuEvent = {
|
||||
|
||||
type TaskUser = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
profileIcon: ProfileIcon;
|
||||
};
|
||||
|
||||
@ -32,6 +31,11 @@ type LoginFormData = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
type DueDateFormData = {
|
||||
endDate: Date;
|
||||
endTime: string | null;
|
||||
};
|
||||
|
||||
type LoginProps = {
|
||||
onSubmit: (
|
||||
data: LoginFormData,
|
||||
|
@ -9,9 +9,21 @@ import { onError } from 'apollo-link-error';
|
||||
import { ApolloLink, Observable, fromPromise } from 'apollo-link';
|
||||
|
||||
import { getAccessToken, getNewToken, setAccessToken } from 'shared/utils/accessToken';
|
||||
import axios from 'axios';
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// Function that will be called to refresh authorization
|
||||
const refreshAuthLogic = (failedRequest: any) =>
|
||||
axios.post('http://localhost:3333/auth/refresh_token', {}, { withCredentials: true }).then(tokenRefreshResponse => {
|
||||
setAccessToken(tokenRefreshResponse.data.accessToken);
|
||||
failedRequest.response.config.headers.Authorization = `Bearer ${tokenRefreshResponse.data.accessToken}`;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
createAuthRefreshInterceptor(axios, refreshAuthLogic);
|
||||
|
||||
// https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8
|
||||
|
||||
let forward$;
|
||||
|
25
web/src/shared/components/Admin/Admin.stories.tsx
Normal file
25
web/src/shared/components/Admin/Admin.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useRef } from 'react';
|
||||
import Admin from '.';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
|
||||
export default {
|
||||
component: Admin,
|
||||
title: 'Admin',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Admin />
|
||||
</>
|
||||
);
|
||||
};
|
323
web/src/shared/components/Admin/index.tsx
Normal file
323
web/src/shared/components/Admin/index.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { User, Plus } from 'shared/icons';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
|
||||
import 'ag-grid-community/dist/styles/ag-grid.css';
|
||||
import 'ag-grid-community/dist/styles/ag-theme-material.css';
|
||||
|
||||
const NewUserButton = styled.button`
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 20px;
|
||||
padding: 0.75rem;
|
||||
background-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(115, 103, 240);
|
||||
font-size: 14px;
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-image: initial;
|
||||
border-color: rgba(115, 103, 240);
|
||||
span {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
`;
|
||||
const GridTable = styled.div`
|
||||
height: 620px;
|
||||
`;
|
||||
|
||||
const RootWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Root = styled.div`
|
||||
.ag-theme-material {
|
||||
--ag-foreground-color: #c2c6dc;
|
||||
--ag-secondary-foreground-color: #c2c6dc;
|
||||
--ag-background-color: transparent;
|
||||
--ag-header-background-color: transparent;
|
||||
--ag-header-foreground-color: #c2c6dc;
|
||||
--ag-border-color: #414561;
|
||||
|
||||
--ag-row-hover-color: #262c49;
|
||||
--ag-header-cell-hover-background-color: #262c49;
|
||||
--ag-checkbox-unchecked-color: #c2c6dc;
|
||||
--ag-checkbox-indeterminate-color: rgba(115, 103, 240);
|
||||
--ag-selected-row-background-color: #262c49;
|
||||
--ag-material-primary-color: rgba(115, 103, 240);
|
||||
--ag-material-accent-color: rgba(115, 103, 240);
|
||||
}
|
||||
.ag-theme-material ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.ag-theme-material ::-webkit-scrollbar-track {
|
||||
background: #262c49;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.ag-theme-material ::-webkit-scrollbar-thumb {
|
||||
background: #7367f0;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.ag-header-cell-text {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
border-bottom: 1px solid #e2e2e2;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-bottom-color: #414561;
|
||||
color: #fff;
|
||||
|
||||
height: 112px;
|
||||
min-height: 112px;
|
||||
`;
|
||||
|
||||
const ActionButtons = () => {
|
||||
return <span>Hello!</span>;
|
||||
};
|
||||
const data = {
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
minWidth: 125,
|
||||
width: 125,
|
||||
headerCheckboxSelection: true,
|
||||
checkboxSelection: true,
|
||||
headerName: 'ID',
|
||||
field: 'id',
|
||||
},
|
||||
{ minWidth: 210, headerName: 'Username', editable: true, field: 'username' },
|
||||
{ minWidth: 225, headerName: 'Email', field: 'email' },
|
||||
{ minWidth: 200, headerName: 'Name', editable: true, field: 'full_name' },
|
||||
{ minWidth: 200, headerName: 'Role', editable: true, field: 'role' },
|
||||
{
|
||||
minWidth: 200,
|
||||
headerName: 'Actions',
|
||||
cellRenderer: 'actionButtons',
|
||||
},
|
||||
],
|
||||
frameworkComponents: {
|
||||
actionButtons: ActionButtons,
|
||||
},
|
||||
rowData: [
|
||||
{ id: '1', full_name: 'Jordan Knott', username: 'jordan', email: 'jordan@jordanthedev.com', role: 'Admin' },
|
||||
{ id: '2', full_name: 'Jordan Test', username: 'jordantest', email: 'jordan@jordanthedev.com', role: 'Admin' },
|
||||
{ id: '3', full_name: 'Jordan Other', username: 'alphatest1050', email: 'jordan@jordanthedev.com', role: 'Admin' },
|
||||
{ id: '5', full_name: 'Jordan French', username: 'other', email: 'jordan@jordanthedev.com', role: 'Admin' },
|
||||
],
|
||||
};
|
||||
const ListTable = () => {
|
||||
return (
|
||||
<Root>
|
||||
<div className="ag-theme-material" style={{ height: '296px', width: '100%' }}>
|
||||
<AgGridReact
|
||||
rowSelection="multiple"
|
||||
defaultColDef={data.defaultColDef}
|
||||
columnDefs={data.columnDefs}
|
||||
rowData={data.rowData}
|
||||
frameworkComponents={data.frameworkComponents}
|
||||
onFirstDataRendered={params => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
onGridSizeChanged={params => {
|
||||
params.api.sizeColumnsToFit();
|
||||
}}
|
||||
></AgGridReact>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background: #eff2f7;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 2.2rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNav = styled.div`
|
||||
float: left;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavContent = styled.ul`
|
||||
display: block;
|
||||
width: auto;
|
||||
border-bottom: 0 !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const TabNavItem = styled.li`
|
||||
padding: 0.35rem 0.3rem;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
|
||||
&:hover {
|
||||
color: rgba(115, 103, 240);
|
||||
}
|
||||
&:hover svg {
|
||||
fill: rgba(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
padding-left: 9px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TabNavLine = styled.span<{ top: number }>`
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
transform: scaleX(1);
|
||||
top: ${props => props.top}px;
|
||||
|
||||
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
|
||||
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
background-color: #10163a;
|
||||
margin-left: 1rem !important;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
const items = [
|
||||
{ name: 'Insights' },
|
||||
{ name: 'Members' },
|
||||
{ name: 'Teams' },
|
||||
{ name: 'Security' },
|
||||
{ name: 'Settings' },
|
||||
];
|
||||
|
||||
type NavItemProps = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
tab: number;
|
||||
onClick: (tab: number, top: number) => void;
|
||||
};
|
||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
const $item = useRef<HTMLLIElement>(null);
|
||||
return (
|
||||
<TabNavItem
|
||||
key={name}
|
||||
ref={$item}
|
||||
onClick={() => {
|
||||
if ($item && $item.current) {
|
||||
const pos = $item.current.getBoundingClientRect();
|
||||
onClick(tab, pos.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Admin = () => {
|
||||
const [currentTop, setTop] = useState(0);
|
||||
const [currentTab, setTab] = useState(0);
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
setTab(tab);
|
||||
setTop(top - pos.top);
|
||||
}
|
||||
}}
|
||||
name={item.name}
|
||||
tab={idx}
|
||||
active={idx === currentTab}
|
||||
/>
|
||||
))}
|
||||
<TabNavLine top={currentTop} />
|
||||
</TabNavContent>
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<TabContent>
|
||||
<NewUserButton>
|
||||
<Plus color="rgba(115, 103, 240)" size={10} />
|
||||
<span>Add New</span>
|
||||
</NewUserButton>
|
||||
<ListTable />
|
||||
</TabContent>
|
||||
</TabContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
@ -21,6 +21,7 @@ import {
|
||||
CardTitle,
|
||||
CardMembers,
|
||||
} from './Styles';
|
||||
import TaskAssignee from 'shared/components/TaskAssignee';
|
||||
|
||||
type DueDate = {
|
||||
isPastDue: boolean;
|
||||
@ -143,7 +144,16 @@ const Card = React.forwardRef(
|
||||
<CardMembers>
|
||||
{members &&
|
||||
members.map(member => (
|
||||
<Member key={member.id} taskID={taskID} member={member} onCardMemberClick={onCardMemberClick} />
|
||||
<TaskAssignee
|
||||
key={member.id}
|
||||
size={28}
|
||||
member={member}
|
||||
onMemberProfile={$target => {
|
||||
if (onCardMemberClick) {
|
||||
onCardMemberClick($target, taskID, member.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</CardMembers>
|
||||
</ListCardDetails>
|
||||
|
@ -33,4 +33,29 @@ const DropdownMenu: React.FC<DropdownMenuProps> = ({ left, top, onLogout, onClos
|
||||
);
|
||||
};
|
||||
|
||||
type ProfileMenuProps = {
|
||||
onProfile: () => void;
|
||||
onLogout: () => void;
|
||||
};
|
||||
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ onProfile, onLogout }) => {
|
||||
return (
|
||||
<>
|
||||
<ActionItem onClick={onProfile}>
|
||||
<User size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Profile</ActionTitle>
|
||||
</ActionItem>
|
||||
<Separator />
|
||||
<ActionsList>
|
||||
<ActionItem onClick={onLogout}>
|
||||
<Exit size={16} color="#c2c6dc" />
|
||||
<ActionTitle>Logout</ActionTitle>
|
||||
</ActionItem>
|
||||
</ActionsList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProfileMenu };
|
||||
|
||||
export default DropdownMenu;
|
||||
|
@ -1,7 +1,12 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import DueDateManager from '.';
|
||||
import { Popup } from '../PopupMenu';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const PopupWrapper = styled.div`
|
||||
width: 300px;
|
||||
`;
|
||||
export default {
|
||||
component: DueDateManager,
|
||||
title: 'DueDateManager',
|
||||
@ -15,41 +20,44 @@ export default {
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<DueDateManager
|
||||
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: [
|
||||
{
|
||||
<PopupWrapper>
|
||||
<Popup title={null} tab={0}>
|
||||
<DueDateManager
|
||||
task={{
|
||||
id: '1',
|
||||
profileIcon: { url: null, initials: null, bgColor: null },
|
||||
firstName: 'Jordan',
|
||||
lastName: 'Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancel={action('cancel')}
|
||||
onDueDateChange={action('due date change')}
|
||||
/>
|
||||
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: { url: null, initials: null, bgColor: null },
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
onCancel={action('cancel')}
|
||||
onDueDateChange={action('due date change')}
|
||||
/>
|
||||
</Popup>
|
||||
</PopupWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -1,8 +1,62 @@
|
||||
import styled from 'styled-components';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex
|
||||
flex-direction: column;
|
||||
& .react-datepicker {
|
||||
background: #262c49;
|
||||
font-family: 'Droid Sans', sans-serif;
|
||||
border: none;
|
||||
|
||||
}
|
||||
& .react-datepicker__day-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 12px40px
|
||||
line-height: 40px;
|
||||
}
|
||||
& .react-datepicker__day-name:hover {
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__month {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .react-datepicker__day,
|
||||
& .react-datepicker__time-name {
|
||||
color: #c2c6dc;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
& .react-datepicker__day--outside-month {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
& .react-datepicker__day:hover {
|
||||
border-radius: 50%;
|
||||
background: #10163a;
|
||||
}
|
||||
& .react-datepicker__day--selected {
|
||||
border-radius: 50%;
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__day--selected:hover {
|
||||
border-radius: 50%;
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
& .react-datepicker__header {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export const DueDatePickerWrapper = styled.div`
|
||||
|
@ -1,23 +1,202 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import moment from 'moment';
|
||||
import styled from 'styled-components';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import { Cross } from 'shared/icons';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Wrapper, ActionWrapper, DueDatePickerWrapper, ConfirmAddDueDate, CancelDueDate } from './Styles';
|
||||
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { getYear, getMonth } from 'date-fns';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
type DueDateManagerProps = {
|
||||
task: Task;
|
||||
onDueDateChange: (task: Task, newDueDate: Date) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onCancel }) => {
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
|
||||
const HeaderSelectLabel = styled.div`
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 9999;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
text-decoration: underline;
|
||||
margin: 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
color: #c2c6dc;
|
||||
|
||||
&:hover {
|
||||
background: rgba(115, 103, 240);
|
||||
color: #c2c6dc;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderSelect = styled.select`
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 4px 6px;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
|
||||
&:hover {
|
||||
background: #262c49;
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
color: #c2c6dc;
|
||||
}
|
||||
|
||||
&::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
z-index: 9998;
|
||||
margin: 0;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
const HeaderButton = styled.button`
|
||||
cursor: pointer;
|
||||
color: #c2c6dc;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
margin: 6px 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background: rgba(115, 103, 240);
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderActions = styled.div`
|
||||
position: relative;
|
||||
text-align: center;
|
||||
& > button:first-child {
|
||||
float: left;
|
||||
}
|
||||
& > button:last-child {
|
||||
float: right;
|
||||
}
|
||||
`;
|
||||
|
||||
const DueDateManager: React.FC<DueDateManagerProps> = ({ task, onDueDateChange, onCancel }) => {
|
||||
const now = moment();
|
||||
const [textStartDate, setTextStartDate] = useState(now.format('YYYY-MM-DD'));
|
||||
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
useEffect(() => {
|
||||
setTextStartDate(moment(startDate).format('YYYY-MM-DD'));
|
||||
}, [startDate]);
|
||||
|
||||
const years = _.range(2010, getYear(new Date()) + 10, 1);
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
const { register, handleSubmit, errors, setError, formState } = useForm<DueDateFormData>();
|
||||
console.log(errors);
|
||||
return (
|
||||
<Wrapper>
|
||||
<form>
|
||||
<input
|
||||
type="text"
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
onChange={e => {
|
||||
setTextStartDate(e.currentTarget.value);
|
||||
}}
|
||||
value={textStartDate}
|
||||
ref={register({
|
||||
required: 'End due date is required.',
|
||||
validate: value => {
|
||||
const isValid = moment(value, 'YYYY-MM-DD').isValid();
|
||||
console.log(`${value} - ${isValid}`);
|
||||
return isValid;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</form>
|
||||
<DueDatePickerWrapper>
|
||||
<DatePicker inline selected={startDate} onChange={date => setStartDate(date ?? new Date())} />
|
||||
<DatePicker
|
||||
useWeekdaysShort
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
changeYear,
|
||||
changeMonth,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<HeaderActions>
|
||||
<HeaderButton onClick={decreaseMonth} disabled={prevMonthButtonDisabled}>
|
||||
Prev
|
||||
</HeaderButton>
|
||||
<HeaderSelectLabel>
|
||||
{months[date.getMonth()]}
|
||||
<HeaderSelect value={getYear(date)} onChange={({ target: { value } }) => changeYear(parseInt(value))}>
|
||||
{years.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
<HeaderSelectLabel>
|
||||
{date.getFullYear()}
|
||||
<HeaderSelect
|
||||
value={months[getMonth(date)]}
|
||||
onChange={({ target: { value } }) => changeMonth(months.indexOf(value))}
|
||||
>
|
||||
{months.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</HeaderSelect>
|
||||
</HeaderSelectLabel>
|
||||
|
||||
<HeaderButton onClick={increaseMonth} disabled={nextMonthButtonDisabled}>
|
||||
Next
|
||||
</HeaderButton>
|
||||
</HeaderActions>
|
||||
)}
|
||||
selected={startDate}
|
||||
inline
|
||||
onChange={date => setStartDate(date ?? new Date())}
|
||||
/>
|
||||
</DueDatePickerWrapper>
|
||||
<ActionWrapper>
|
||||
<ConfirmAddDueDate onClick={() => onDueDateChange(task, startDate)}>Save</ConfirmAddDueDate>
|
||||
|
@ -64,6 +64,7 @@ export const HeaderEditTarget = styled.div<{ isHidden: boolean }>`
|
||||
|
||||
export const HeaderName = styled(TextareaAutosize)`
|
||||
font-family: 'Droid Sans';
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
|
@ -171,6 +171,7 @@ export const ListsWithManyList = () => {
|
||||
onCreateTask={action('card create')}
|
||||
onTaskDrop={onCardDrop}
|
||||
onTaskGroupDrop={onListDrop}
|
||||
onChangeTaskGroupName={action('change group name')}
|
||||
onCreateTaskGroup={action('create list')}
|
||||
onExtraMenuOpen={action('extra menu open')}
|
||||
onCardMemberClick={action('card member click')}
|
||||
|
@ -20,6 +20,7 @@ interface SimpleProps {
|
||||
|
||||
onTaskClick: (task: Task) => void;
|
||||
onCreateTask: (taskGroupID: string, name: string) => void;
|
||||
onChangeTaskGroupName: (taskGroupID: string, name: string) => void;
|
||||
onQuickEditorOpen: (e: ContextMenuEvent) => void;
|
||||
onCreateTaskGroup: (listName: string) => void;
|
||||
onExtraMenuOpen: (taskGroupID: string, $targetRef: React.RefObject<HTMLElement>) => void;
|
||||
@ -29,6 +30,7 @@ interface SimpleProps {
|
||||
const SimpleLists: React.FC<SimpleProps> = ({
|
||||
taskGroups,
|
||||
onTaskDrop,
|
||||
onChangeTaskGroupName,
|
||||
onTaskGroupDrop,
|
||||
onTaskClick,
|
||||
onCreateTask,
|
||||
@ -135,7 +137,7 @@ const SimpleLists: React.FC<SimpleProps> = ({
|
||||
name={taskGroup.name}
|
||||
onOpenComposer={id => setCurrentComposer(id)}
|
||||
isComposerOpen={currentComposer === taskGroup.id}
|
||||
onSaveName={name => {}}
|
||||
onSaveName={name => onChangeTaskGroupName(taskGroup.id, name)}
|
||||
ref={columnDragProvided.innerRef}
|
||||
wrapperProps={columnDragProvided.draggableProps}
|
||||
headerProps={columnDragProvided.dragHandleProps}
|
||||
|
@ -101,3 +101,24 @@ export const RegisterButton = styled.button`
|
||||
color: rgba(115, 103, 240);
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export const LogoTitle = styled.div`
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-left: 12px;
|
||||
transition: visibility, opacity, transform 0.25s ease;
|
||||
color: #7367f0;
|
||||
`;
|
||||
|
||||
export const LogoWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
color: rgb(222, 235, 255);
|
||||
border-bottom: 1px solid rgba(65, 69, 97, 0.65);
|
||||
`;
|
||||
|
@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import AccessAccount from 'shared/undraw/AccessAccount';
|
||||
import { User, Lock } from 'shared/icons';
|
||||
import { User, Lock, Citadel } from 'shared/icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Form,
|
||||
LogoWrapper,
|
||||
LogoTitle,
|
||||
ActionButtons,
|
||||
RegisterButton,
|
||||
LoginButton,
|
||||
@ -35,6 +37,10 @@ const Login = ({ onSubmit }: LoginProps) => {
|
||||
<Column>
|
||||
<LoginFormWrapper>
|
||||
<LoginFormContainer>
|
||||
<LogoWrapper>
|
||||
<Citadel size={42} />
|
||||
<LogoTitle>Citadel</LogoTitle>
|
||||
</LogoWrapper>
|
||||
<Title>Login</Title>
|
||||
<SubTitle>Welcome back, please login into your account.</SubTitle>
|
||||
<Form onSubmit={handleSubmit(loginSubmit)}>
|
||||
|
@ -39,9 +39,7 @@ const MemberManager: React.FC<MemberManagerProps> = ({
|
||||
<BoardMembersList>
|
||||
{availableMembers
|
||||
.filter(
|
||||
member =>
|
||||
currentSearch === '' ||
|
||||
`${member.firstName} ${member.lastName}`.toLowerCase().startsWith(currentSearch.toLowerCase()),
|
||||
member => currentSearch === '' || member.fullName.toLowerCase().startsWith(currentSearch.toLowerCase()),
|
||||
)
|
||||
.map(member => {
|
||||
return (
|
||||
@ -58,7 +56,7 @@ const MemberManager: React.FC<MemberManagerProps> = ({
|
||||
}}
|
||||
>
|
||||
<ProfileIcon>JK</ProfileIcon>
|
||||
<MemberName>{`${member.firstName} ${member.lastName}`}</MemberName>
|
||||
<MemberName>{member.fullName}</MemberName>
|
||||
{activeMembers.findIndex(m => m.id === member.id) !== -1 && (
|
||||
<ActiveIconWrapper>
|
||||
<Checkmark size={16} color="#42526e" />
|
||||
|
@ -227,8 +227,7 @@ export const MemberManagerPopup = () => {
|
||||
availableMembers={[
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'Jordan',
|
||||
lastName: 'Knott',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
},
|
||||
]}
|
||||
@ -293,8 +292,7 @@ export const DueDateManagerPopup = () => {
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
firstName: 'Jordan',
|
||||
lastName: 'Knott',
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
46
web/src/shared/components/ProfileIcon/index.tsx
Normal file
46
web/src/shared/components/ProfileIcon/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
|
||||
margin-left: 10px;
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
type ProfileIconProps = {
|
||||
user: TaskUser;
|
||||
onProfileClick: ($target: React.RefObject<HTMLElement>, user: TaskUser) => void;
|
||||
size: number | string;
|
||||
};
|
||||
|
||||
const ProfileIcon: React.FC<ProfileIconProps> = ({ user, onProfileClick, size }) => {
|
||||
const $profileRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container
|
||||
ref={$profileRef}
|
||||
onClick={() => {
|
||||
onProfileClick($profileRef, user);
|
||||
}}
|
||||
size={size}
|
||||
backgroundURL={user.profileIcon.url ?? null}
|
||||
bgColor={user.profileIcon.bgColor ?? null}
|
||||
>
|
||||
{(!user.profileIcon.url && user.profileIcon.initials) ?? ''}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileIcon.defaultProps = {
|
||||
size: 28,
|
||||
};
|
||||
|
||||
export default ProfileIcon;
|
@ -3,14 +3,14 @@ import TextareaAutosize from 'react-autosize-textarea';
|
||||
import { mixin } from 'shared/utils/styles';
|
||||
|
||||
export const Wrapper = styled.div<{ open: boolean }>`
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
bottom: 0;
|
||||
color: #fff;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
z-index: 100;
|
||||
visibility: ${props => (props.open ? 'show' : 'hidden')};
|
||||
`;
|
||||
|
||||
|
30
web/src/shared/components/Settings/Settings.stories.tsx
Normal file
30
web/src/shared/components/Settings/Settings.stories.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { createRef, useState } from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Settings from '.';
|
||||
|
||||
export default {
|
||||
component: Settings,
|
||||
title: 'Settings',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'white', value: '#ffffff', default: true },
|
||||
{ name: 'gray', value: '#f8f8f8' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const profile = { url: 'http://localhost:3333/uploads/headshot.png', bgColor: '#000', initials: 'JK' };
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Settings
|
||||
profile={profile}
|
||||
onProfileAvatarRemove={action('remove')}
|
||||
onProfileAvatarChange={action('profile avatar change')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
362
web/src/shared/components/Settings/index.tsx
Normal file
362
web/src/shared/components/Settings/index.tsx
Normal file
@ -0,0 +1,362 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { User } from 'shared/icons';
|
||||
|
||||
const TextFieldWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
||||
margin-bottom: 2.2rem;
|
||||
margin-top: 17px;
|
||||
`;
|
||||
|
||||
const TextFieldLabel = styled.span`
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
cursor: text;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const TextFieldInput = styled.input`
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
background: #262c49;
|
||||
padding: 0.7rem !important;
|
||||
color: #c2c6dc;
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s ease;
|
||||
width: 100%;
|
||||
&:focus {
|
||||
box-shadow: 0 3px 10px 0 rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(115, 103, 240);
|
||||
}
|
||||
&:focus ~ ${TextFieldLabel} {
|
||||
color: rgba(115, 103, 240);
|
||||
transform: translate(-3px, -90%);
|
||||
}
|
||||
`;
|
||||
|
||||
type TextFieldProps = {
|
||||
label: string;
|
||||
};
|
||||
const TextField: React.FC<TextFieldProps> = ({ label }) => {
|
||||
return (
|
||||
<TextFieldWrapper>
|
||||
<TextFieldInput />
|
||||
<TextFieldLabel>{label}</TextFieldLabel>
|
||||
</TextFieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2.2rem !important;
|
||||
`;
|
||||
|
||||
const AvatarContainer = styled.div`
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
`;
|
||||
const AvatarMask = styled.div<{ background: string }>`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: ${props => props.background};
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const AvatarImg = styled.img<{ src: string }>`
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const ActionButtons = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
`;
|
||||
const UploadButton = styled.div`
|
||||
margin-right: 1rem;
|
||||
padding: 0.75rem 2rem;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
background: rgba(115, 103, 240);
|
||||
`;
|
||||
|
||||
const RemoveButton = styled.button`
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(234, 84, 85, 1);
|
||||
background: transparent;
|
||||
color: rgba(234, 84, 85, 1);
|
||||
padding: 0.75rem 2rem;
|
||||
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ImgLabel = styled.p`
|
||||
color: #c2c6dc;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 12.25px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const AvatarInitials = styled.span`
|
||||
font-size: 32px;
|
||||
color: #fff;
|
||||
`;
|
||||
|
||||
type AvatarSettingsProps = {
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
profile: ProfileIcon;
|
||||
};
|
||||
|
||||
const AvatarSettings: React.FC<AvatarSettingsProps> = ({ profile, onProfileAvatarChange, onProfileAvatarRemove }) => {
|
||||
return (
|
||||
<ProfileContainer>
|
||||
<AvatarContainer>
|
||||
<AvatarMask
|
||||
background={profile.url ? 'none' : profile.bgColor ?? 'none'}
|
||||
onClick={() => onProfileAvatarChange()}
|
||||
>
|
||||
{profile.url ? (
|
||||
<AvatarImg alt="" src={profile.url ?? ''} />
|
||||
) : (
|
||||
<AvatarInitials>{profile.initials}</AvatarInitials>
|
||||
)}
|
||||
</AvatarMask>
|
||||
</AvatarContainer>
|
||||
<ActionButtons>
|
||||
<UploadButton onClick={() => onProfileAvatarChange()}>Upload photo</UploadButton>
|
||||
<RemoveButton onClick={() => onProfileAvatarRemove()}>Remove</RemoveButton>
|
||||
<ImgLabel>Allowed JPG, GIF or PNG. Max size of 800kB</ImgLabel>
|
||||
</ActionButtons>
|
||||
</ProfileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 2.2rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNav = styled.div`
|
||||
float: left;
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TabNavContent = styled.ul`
|
||||
display: block;
|
||||
width: auto;
|
||||
border-bottom: 0 !important;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.05);
|
||||
`;
|
||||
|
||||
const TabNavItem = styled.li`
|
||||
padding: 0.35rem 0.3rem;
|
||||
display: block;
|
||||
position: relative;
|
||||
`;
|
||||
const TabNavItemButton = styled.button<{ active: boolean }>`
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding-top: 10px !important;
|
||||
padding-bottom: 10px !important;
|
||||
padding-left: 12px !important;
|
||||
padding-right: 8px !important;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
color: ${props => (props.active ? 'rgba(115, 103, 240)' : '#c2c6dc')};
|
||||
&:hover {
|
||||
color: rgba(115, 103, 240);
|
||||
}
|
||||
&:hover svg {
|
||||
fill: rgba(115, 103, 240);
|
||||
}
|
||||
`;
|
||||
|
||||
const TabNavItemSpan = styled.span`
|
||||
text-align: left;
|
||||
padding-left: 9px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const TabNavLine = styled.span<{ top: number }>`
|
||||
left: auto;
|
||||
right: 0;
|
||||
width: 2px;
|
||||
height: 48px;
|
||||
transform: scaleX(1);
|
||||
top: ${props => props.top}px;
|
||||
|
||||
background: linear-gradient(30deg, rgba(115, 103, 240), rgba(115, 103, 240));
|
||||
box-shadow: 0 0 8px 0 rgba(115, 103, 240);
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
const TabContentWrapper = styled.div`
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const TabContent = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
background-color: #10163a;
|
||||
margin-left: 1rem !important;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
const TabContentInner = styled.div``;
|
||||
|
||||
const items = [{ name: 'General' }, { name: 'Change Password' }, { name: 'Info' }, { name: 'Notifications' }];
|
||||
type NavItemProps = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
tab: number;
|
||||
onClick: (tab: number, top: number) => void;
|
||||
};
|
||||
const NavItem: React.FC<NavItemProps> = ({ active, name, tab, onClick }) => {
|
||||
const $item = useRef<HTMLLIElement>(null);
|
||||
return (
|
||||
<TabNavItem
|
||||
key={name}
|
||||
ref={$item}
|
||||
onClick={() => {
|
||||
if ($item && $item.current) {
|
||||
const pos = $item.current.getBoundingClientRect();
|
||||
onClick(tab, pos.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabNavItemButton active={active}>
|
||||
<User size={14} color={active ? 'rgba(115, 103, 240)' : '#c2c6dc'} />
|
||||
<TabNavItemSpan>{name}</TabNavItemSpan>
|
||||
</TabNavItemButton>
|
||||
</TabNavItem>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingActions = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const SaveButton = styled.div`
|
||||
margin-right: 1rem;
|
||||
padding: 0.75rem 2rem;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
background: rgba(115, 103, 240);
|
||||
`;
|
||||
|
||||
type SettingsProps = {
|
||||
onProfileAvatarChange: () => void;
|
||||
onProfileAvatarRemove: () => void;
|
||||
profile: ProfileIcon;
|
||||
};
|
||||
|
||||
const Settings: React.FC<SettingsProps> = ({ onProfileAvatarRemove, onProfileAvatarChange, profile }) => {
|
||||
const [currentTab, setTab] = useState(0);
|
||||
const [currentTop, setTop] = useState(0);
|
||||
const $tabNav = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Container>
|
||||
<TabNav ref={$tabNav}>
|
||||
<TabNavContent>
|
||||
{items.map((item, idx) => (
|
||||
<NavItem
|
||||
onClick={(tab, top) => {
|
||||
if ($tabNav && $tabNav.current) {
|
||||
const pos = $tabNav.current.getBoundingClientRect();
|
||||
setTab(tab);
|
||||
setTop(top - pos.top);
|
||||
}
|
||||
}}
|
||||
name={item.name}
|
||||
tab={idx}
|
||||
active={idx === currentTab}
|
||||
/>
|
||||
))}
|
||||
<TabNavLine top={currentTop} />
|
||||
</TabNavContent>
|
||||
</TabNav>
|
||||
<TabContentWrapper>
|
||||
<TabContent>
|
||||
<AvatarSettings
|
||||
onProfileAvatarRemove={onProfileAvatarRemove}
|
||||
onProfileAvatarChange={onProfileAvatarChange}
|
||||
profile={profile}
|
||||
/>
|
||||
<TextField label="Name" />
|
||||
<TextField label="Initials " />
|
||||
<TextField label="Username " />
|
||||
<TextField label="Email" />
|
||||
<TextField label="Bio" />
|
||||
<SettingActions>
|
||||
<SaveButton>Save Change</SaveButton>
|
||||
</SettingActions>
|
||||
</TabContent>
|
||||
</TabContentWrapper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
25
web/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
25
web/src/shared/components/Tabs/Tabs.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React, { useRef } from 'react';
|
||||
import NormalizeStyles from 'App/NormalizeStyles';
|
||||
import BaseStyles from 'App/BaseStyles';
|
||||
import Tabs from '.';
|
||||
|
||||
export default {
|
||||
component: Tabs,
|
||||
title: 'Tabs',
|
||||
parameters: {
|
||||
backgrounds: [
|
||||
{ name: 'gray', value: '#f8f8f8', default: true },
|
||||
{ name: 'white', value: '#ffffff' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<Tabs />
|
||||
</>
|
||||
);
|
||||
};
|
8
web/src/shared/components/Tabs/index.tsx
Normal file
8
web/src/shared/components/Tabs/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Tabs = () => {
|
||||
return <span>HEllo!</span>;
|
||||
};
|
||||
|
||||
export default Tabs;
|
@ -8,7 +8,7 @@ const TaskDetailAssignee = styled.div`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const ProfileIcon = styled.div<{ size: string | number }>`
|
||||
export const Wrapper = styled.div<{ size: number | string; bgColor: string | null; backgroundURL: string | null }>`
|
||||
width: ${props => props.size}px;
|
||||
height: ${props => props.size}px;
|
||||
border-radius: 9999px;
|
||||
@ -16,10 +16,10 @@ const ProfileIcon = styled.div<{ size: string | number }>`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
background: rgb(115, 103, 240);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
type TaskAssigneeProps = {
|
||||
@ -31,8 +31,17 @@ type TaskAssigneeProps = {
|
||||
const TaskAssignee: React.FC<TaskAssigneeProps> = ({ member, onMemberProfile, size }) => {
|
||||
const $memberRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<TaskDetailAssignee ref={$memberRef} onClick={() => onMemberProfile($memberRef, member.id)} key={member.id}>
|
||||
<ProfileIcon size={size}>{member.profileIcon.initials ?? ''}</ProfileIcon>
|
||||
<TaskDetailAssignee
|
||||
ref={$memberRef}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onMemberProfile($memberRef, member.id);
|
||||
}}
|
||||
key={member.id}
|
||||
>
|
||||
<Wrapper backgroundURL={member.profileIcon.url ?? null} bgColor={member.profileIcon.bgColor ?? null} size={size}>
|
||||
{(!member.profileIcon.url && member.profileIcon.initials) ?? ''}
|
||||
</Wrapper>
|
||||
</TaskDetailAssignee>
|
||||
);
|
||||
};
|
||||
|
@ -55,8 +55,7 @@ export const Default = () => {
|
||||
{
|
||||
id: '1',
|
||||
profileIcon: { bgColor: null, url: null, initials: null },
|
||||
firstName: 'Jordan',
|
||||
lastName: 'Knott',
|
||||
fullName: 'Jordan Knott',
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
@ -68,7 +68,7 @@ export const ProfileNameSecondary = styled.small`
|
||||
color: #c2c6dc;
|
||||
`;
|
||||
|
||||
export const ProfileIcon = styled.div<{ bgColor: string }>`
|
||||
export const ProfileIcon = styled.div<{ bgColor: string | null; backgroundURL: string | null }>`
|
||||
margin-left: 10px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -78,8 +78,9 @@ export const ProfileIcon = styled.div<{ bgColor: string }>`
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
background: ${props => props.bgColor};
|
||||
cursor: pointer;
|
||||
background: ${props => (props.backgroundURL ? `url(${props.backgroundURL})` : props.bgColor)};
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
`;
|
||||
|
||||
export const ProjectMeta = styled.div`
|
||||
|
@ -21,42 +21,25 @@ export default {
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const [menu, setMenu] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
isOpen: false,
|
||||
});
|
||||
const onClick = (bottom: number, right: number) => {
|
||||
setMenu({
|
||||
isOpen: !menu.isOpen,
|
||||
left: right,
|
||||
top: bottom,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<NormalizeStyles />
|
||||
<BaseStyles />
|
||||
<TopNavbar
|
||||
projectName="Projects"
|
||||
bgColor="#7367F0"
|
||||
firstName="Jordan"
|
||||
lastName="Knott"
|
||||
initials="JK"
|
||||
user={{
|
||||
id: '1',
|
||||
fullName: 'Jordan Knott',
|
||||
profileIcon: {
|
||||
url: null,
|
||||
initials: 'JK',
|
||||
bgColor: '#000',
|
||||
},
|
||||
}}
|
||||
onNotificationClick={action('notifications click')}
|
||||
onOpenSettings={action('open settings')}
|
||||
onProfileClick={onClick}
|
||||
onProfileClick={action('profile click')}
|
||||
/>
|
||||
{menu.isOpen && (
|
||||
<DropdownMenu
|
||||
onCloseDropdown={() => {
|
||||
setMenu({ left: 0, top: 0, isOpen: false });
|
||||
}}
|
||||
onLogout={action('on logout')}
|
||||
left={menu.left}
|
||||
top={menu.top}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Star, Ellipsis, Bell, Cog, AngleDown } from 'shared/icons';
|
||||
|
||||
import ProfileIcon from 'shared/components/ProfileIcon';
|
||||
import {
|
||||
NotificationContainer,
|
||||
ProjectNameTextarea,
|
||||
@ -18,7 +18,6 @@ import {
|
||||
Breadcrumbs,
|
||||
BreadcrumpSeparator,
|
||||
ProjectSettingsButton,
|
||||
ProfileIcon,
|
||||
ProfileContainer,
|
||||
ProfileNameWrapper,
|
||||
ProfileNamePrimary,
|
||||
@ -110,14 +109,11 @@ const ProjectHeading: React.FC<ProjectHeadingProps> = ({
|
||||
|
||||
type NavBarProps = {
|
||||
projectName: string | null;
|
||||
onProfileClick: (bottom: number, right: number) => void;
|
||||
onProfileClick: ($target: React.RefObject<HTMLElement>) => void;
|
||||
onSaveProjectName?: (projectName: string) => void;
|
||||
onNotificationClick: () => void;
|
||||
bgColor: string;
|
||||
user: TaskUser | null;
|
||||
onOpenSettings: ($target: React.RefObject<HTMLElement>) => void;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
initials: string;
|
||||
projectMembers?: Array<TaskUser> | null;
|
||||
};
|
||||
|
||||
@ -126,17 +122,14 @@ const NavBar: React.FC<NavBarProps> = ({
|
||||
onSaveProjectName,
|
||||
onProfileClick,
|
||||
onNotificationClick,
|
||||
firstName,
|
||||
lastName,
|
||||
initials,
|
||||
bgColor,
|
||||
user,
|
||||
projectMembers,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const $profileRef: any = useRef(null);
|
||||
const handleProfileClick = () => {
|
||||
const boundingRect = $profileRef.current.getBoundingClientRect();
|
||||
onProfileClick(boundingRect.bottom, boundingRect.right);
|
||||
const handleProfileClick = ($target: React.RefObject<HTMLElement>) => {
|
||||
if ($target && $target.current) {
|
||||
onProfileClick($target);
|
||||
}
|
||||
};
|
||||
const { showPopup } = usePopup();
|
||||
const onMemberProfile = ($targetRef: React.RefObject<HTMLElement>, memberID: string) => {
|
||||
@ -189,15 +182,16 @@ const NavBar: React.FC<NavBarProps> = ({
|
||||
<NotificationContainer onClick={onNotificationClick}>
|
||||
<Bell color="#c2c6dc" size={20} />
|
||||
</NotificationContainer>
|
||||
<ProfileContainer>
|
||||
<ProfileNameWrapper>
|
||||
<ProfileNamePrimary>{`${firstName} ${lastName}`}</ProfileNamePrimary>
|
||||
<ProfileNameSecondary>Manager</ProfileNameSecondary>
|
||||
</ProfileNameWrapper>
|
||||
<ProfileIcon ref={$profileRef} onClick={handleProfileClick} bgColor={bgColor}>
|
||||
{initials}
|
||||
</ProfileIcon>
|
||||
</ProfileContainer>
|
||||
|
||||
{user && (
|
||||
<ProfileContainer>
|
||||
<ProfileNameWrapper>
|
||||
<ProfileNamePrimary>{user.fullName}</ProfileNamePrimary>
|
||||
<ProfileNameSecondary>Manager</ProfileNameSecondary>
|
||||
</ProfileNameWrapper>
|
||||
<ProfileIcon user={user} size={40} onProfileClick={handleProfileClick} />}
|
||||
</ProfileContainer>
|
||||
)}
|
||||
</GlobalActions>
|
||||
</NavbarHeader>
|
||||
</NavbarWrapper>
|
||||
|
@ -11,10 +11,12 @@ export type Scalars = {
|
||||
Float: number;
|
||||
Time: any;
|
||||
UUID: string;
|
||||
Upload: any;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
export type ProjectLabel = {
|
||||
__typename?: 'ProjectLabel';
|
||||
id: Scalars['ID'];
|
||||
@ -48,8 +50,7 @@ export type ProfileIcon = {
|
||||
export type ProjectMember = {
|
||||
__typename?: 'ProjectMember';
|
||||
id: Scalars['ID'];
|
||||
firstName: Scalars['String'];
|
||||
lastName: Scalars['String'];
|
||||
fullName: Scalars['String'];
|
||||
profileIcon: ProfileIcon;
|
||||
};
|
||||
|
||||
@ -66,8 +67,8 @@ export type UserAccount = {
|
||||
id: Scalars['ID'];
|
||||
email: Scalars['String'];
|
||||
createdAt: Scalars['Time'];
|
||||
firstName: Scalars['String'];
|
||||
lastName: Scalars['String'];
|
||||
fullName: Scalars['String'];
|
||||
initials: Scalars['String'];
|
||||
username: Scalars['String'];
|
||||
profileIcon: ProfileIcon;
|
||||
};
|
||||
@ -169,8 +170,8 @@ export type NewRefreshToken = {
|
||||
export type NewUserAccount = {
|
||||
username: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
firstName: Scalars['String'];
|
||||
lastName: Scalars['String'];
|
||||
fullName: Scalars['String'];
|
||||
initials: Scalars['String'];
|
||||
password: Scalars['String'];
|
||||
};
|
||||
|
||||
@ -314,6 +315,7 @@ export type Mutation = {
|
||||
createRefreshToken: RefreshToken;
|
||||
createUserAccount: UserAccount;
|
||||
createTeam: Team;
|
||||
clearProfileAvatar: UserAccount;
|
||||
createProject: Project;
|
||||
updateProjectName: Project;
|
||||
createProjectLabel: ProjectLabel;
|
||||
@ -470,11 +472,26 @@ export type AssignTaskMutation = (
|
||||
& Pick<Task, 'id'>
|
||||
& { assigned: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
)> }
|
||||
) }
|
||||
);
|
||||
|
||||
export type ClearProfileAvatarMutationVariables = {};
|
||||
|
||||
|
||||
export type ClearProfileAvatarMutation = (
|
||||
{ __typename?: 'Mutation' }
|
||||
& { clearProfileAvatar: (
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
|
||||
) }
|
||||
) }
|
||||
);
|
||||
|
||||
export type CreateProjectMutationVariables = {
|
||||
teamID: Scalars['UUID'];
|
||||
userID: Scalars['UUID'];
|
||||
@ -541,7 +558,7 @@ export type CreateTaskMutation = (
|
||||
) }
|
||||
)>, assigned: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
|
||||
@ -624,7 +641,7 @@ export type FindProjectQuery = (
|
||||
& Pick<Project, 'name'>
|
||||
& { members: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
|
||||
@ -658,7 +675,7 @@ export type FindProjectQuery = (
|
||||
) }
|
||||
)>, assigned: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
|
||||
@ -698,7 +715,7 @@ export type FindTaskQuery = (
|
||||
) }
|
||||
)>, assigned: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'url' | 'initials' | 'bgColor'>
|
||||
@ -732,10 +749,10 @@ export type MeQuery = (
|
||||
{ __typename?: 'Query' }
|
||||
& { me: (
|
||||
{ __typename?: 'UserAccount' }
|
||||
& Pick<UserAccount, 'firstName' | 'lastName'>
|
||||
& Pick<UserAccount, 'id' | 'fullName'>
|
||||
& { profileIcon: (
|
||||
{ __typename?: 'ProfileIcon' }
|
||||
& Pick<ProfileIcon, 'initials' | 'bgColor'>
|
||||
& Pick<ProfileIcon, 'initials' | 'bgColor' | 'url'>
|
||||
) }
|
||||
) }
|
||||
);
|
||||
@ -783,7 +800,7 @@ export type UnassignTaskMutation = (
|
||||
& Pick<Task, 'id'>
|
||||
& { assigned: Array<(
|
||||
{ __typename?: 'ProjectMember' }
|
||||
& Pick<ProjectMember, 'id' | 'firstName' | 'lastName'>
|
||||
& Pick<ProjectMember, 'id' | 'fullName'>
|
||||
)> }
|
||||
) }
|
||||
);
|
||||
@ -831,7 +848,7 @@ export type UpdateTaskDescriptionMutation = (
|
||||
{ __typename?: 'Mutation' }
|
||||
& { updateTaskDescription: (
|
||||
{ __typename?: 'Task' }
|
||||
& Pick<Task, 'id'>
|
||||
& Pick<Task, 'id' | 'description'>
|
||||
) }
|
||||
);
|
||||
|
||||
@ -893,8 +910,7 @@ export const AssignTaskDocument = gql`
|
||||
id
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -925,6 +941,43 @@ export function useAssignTaskMutation(baseOptions?: ApolloReactHooks.MutationHoo
|
||||
export type AssignTaskMutationHookResult = ReturnType<typeof useAssignTaskMutation>;
|
||||
export type AssignTaskMutationResult = ApolloReactCommon.MutationResult<AssignTaskMutation>;
|
||||
export type AssignTaskMutationOptions = ApolloReactCommon.BaseMutationOptions<AssignTaskMutation, AssignTaskMutationVariables>;
|
||||
export const ClearProfileAvatarDocument = gql`
|
||||
mutation clearProfileAvatar {
|
||||
clearProfileAvatar {
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type ClearProfileAvatarMutationFn = ApolloReactCommon.MutationFunction<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useClearProfileAvatarMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useClearProfileAvatarMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useClearProfileAvatarMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [clearProfileAvatarMutation, { data, loading, error }] = useClearProfileAvatarMutation({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useClearProfileAvatarMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>) {
|
||||
return ApolloReactHooks.useMutation<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>(ClearProfileAvatarDocument, baseOptions);
|
||||
}
|
||||
export type ClearProfileAvatarMutationHookResult = ReturnType<typeof useClearProfileAvatarMutation>;
|
||||
export type ClearProfileAvatarMutationResult = ApolloReactCommon.MutationResult<ClearProfileAvatarMutation>;
|
||||
export type ClearProfileAvatarMutationOptions = ApolloReactCommon.BaseMutationOptions<ClearProfileAvatarMutation, ClearProfileAvatarMutationVariables>;
|
||||
export const CreateProjectDocument = gql`
|
||||
mutation createProject($teamID: UUID!, $userID: UUID!, $name: String!) {
|
||||
createProject(input: {teamID: $teamID, userID: $userID, name: $name}) {
|
||||
@ -1035,8 +1088,7 @@ export const CreateTaskDocument = gql`
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -1219,8 +1271,7 @@ export const FindProjectDocument = gql`
|
||||
name
|
||||
members {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -1269,8 +1320,7 @@ export const FindProjectDocument = gql`
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -1341,8 +1391,7 @@ export const FindTaskDocument = gql`
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -1423,11 +1472,12 @@ export type GetProjectsQueryResult = ApolloReactCommon.QueryResult<GetProjectsQu
|
||||
export const MeDocument = gql`
|
||||
query me {
|
||||
me {
|
||||
firstName
|
||||
lastName
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1513,8 +1563,7 @@ export const UnassignTaskDocument = gql`
|
||||
unassignTask(input: {taskID: $taskID, userID: $userID}) {
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
id
|
||||
}
|
||||
@ -1626,6 +1675,7 @@ export const UpdateTaskDescriptionDocument = gql`
|
||||
mutation updateTaskDescription($taskID: UUID!, $description: String!) {
|
||||
updateTaskDescription(input: {taskID: $taskID, description: $description}) {
|
||||
id
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -3,8 +3,7 @@ mutation assignTask($taskID: UUID!, $userID: UUID!) {
|
||||
id
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
web/src/shared/graphql/clearAvatarProfile.graphqls
Normal file
11
web/src/shared/graphql/clearAvatarProfile.graphqls
Normal file
@ -0,0 +1,11 @@
|
||||
mutation clearProfileAvatar {
|
||||
clearProfileAvatar {
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
@ -26,8 +26,7 @@ mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
|
@ -3,8 +3,7 @@ query findProject($projectId: String!) {
|
||||
name
|
||||
members {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
@ -53,8 +52,7 @@ query findProject($projectId: String!) {
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
|
@ -24,8 +24,7 @@ query findTask($taskID: UUID!) {
|
||||
}
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
profileIcon {
|
||||
url
|
||||
initials
|
||||
|
@ -1,10 +1,11 @@
|
||||
query me {
|
||||
me {
|
||||
firstName
|
||||
lastName
|
||||
id
|
||||
fullName
|
||||
profileIcon {
|
||||
initials
|
||||
bgColor
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,7 @@ mutation unassignTask($taskID: UUID!, $userID: UUID!) {
|
||||
unassignTask(input: {taskID: $taskID, userID: $userID}) {
|
||||
assigned {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
id
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
mutation updateTaskDescription($taskID: UUID!, $description: String!) {
|
||||
updateTaskDescription(input: {taskID: $taskID, description: $description}) {
|
||||
id
|
||||
description
|
||||
}
|
||||
}
|
||||
|
@ -2971,6 +2971,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
|
||||
integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==
|
||||
|
||||
"@types/axios@^0.14.0":
|
||||
version "0.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46"
|
||||
integrity sha1-7CMA++fX3d1+udOr+HmZlkyvzkY=
|
||||
dependencies:
|
||||
axios "*"
|
||||
|
||||
"@types/babel-types@*", "@types/babel-types@^7.0.0":
|
||||
version "7.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3"
|
||||
@ -3035,6 +3042,13 @@
|
||||
dependencies:
|
||||
"@types/color-convert" "*"
|
||||
|
||||
"@types/date-fns@^2.6.0":
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/date-fns/-/date-fns-2.6.0.tgz#b062ca46562002909be0c63a6467ed173136acc1"
|
||||
integrity sha1-sGLKRlYgApCb4MY6ZGftFzE2rME=
|
||||
dependencies:
|
||||
date-fns "*"
|
||||
|
||||
"@types/eslint-visitor-keys@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
|
||||
@ -3724,6 +3738,18 @@ adjust-sourcemap-loader@2.0.0:
|
||||
object-path "0.11.4"
|
||||
regex-parser "2.2.10"
|
||||
|
||||
ag-grid-community@^23.2.0:
|
||||
version "23.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ag-grid-community/-/ag-grid-community-23.2.0.tgz#889f52e8eb91c167c2ac7477938cbf498a54f67c"
|
||||
integrity sha512-aG7Ghfu79HeqOCd50GhFSeZUX1Tw9BVUX1VKMuglkAcwYPTQjuYvYT7QVQB5FGzfFjcVq4a1QFfcgdoAcZYJIA==
|
||||
|
||||
ag-grid-react@^23.2.0:
|
||||
version "23.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ag-grid-react/-/ag-grid-react-23.2.0.tgz#00a54cb4e83c0d35a49c202e5833e3bb20d8cfa8"
|
||||
integrity sha512-lDGV+WX0Nj5biNOJRSErFehXG+nqkbuXPMS7YJxEDWJLJxtOF0INP5sL6dtxV12j/XHqXa+M2CgQBXZWZq+EWg==
|
||||
dependencies:
|
||||
prop-types "^15.6.2"
|
||||
|
||||
agent-base@4, agent-base@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
|
||||
@ -4399,6 +4425,18 @@ aws4@^1.8.0:
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
|
||||
integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
|
||||
|
||||
axios-auth-refresh@^2.2.7:
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/axios-auth-refresh/-/axios-auth-refresh-2.2.7.tgz#922f458129ed653d9bd0d732743bf9bba4524f12"
|
||||
integrity sha512-5gdrwRG3luW/BHIwyh7vZk9AFkC3tOkWhE4yJJW0Dno36kcTHN8V87SxH52m3HF8bQpORaV3RNmuwlOT8pKOOw==
|
||||
|
||||
axios@*, axios@^0.19.2:
|
||||
version "0.19.2"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"
|
||||
integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==
|
||||
dependencies:
|
||||
follow-redirects "1.5.10"
|
||||
|
||||
axios@0.19.0:
|
||||
version "0.19.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8"
|
||||
@ -6351,6 +6389,11 @@ data-urls@^1.0.0, data-urls@^1.1.0:
|
||||
whatwg-mimetype "^2.2.0"
|
||||
whatwg-url "^7.0.0"
|
||||
|
||||
date-fns@*, date-fns@^2.14.0:
|
||||
version "2.14.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.14.0.tgz#359a87a265bb34ef2e38f93ecf63ac453f9bc7ba"
|
||||
integrity sha512-1zD+68jhFgDIM0rF05rcwYO8cExdNqxjq4xP1QKM60Q45mnO6zaMWB4tOzrIr4M4GSLntsKeE4c9Bdl2jhL/yw==
|
||||
|
||||
date-fns@^1.27.2:
|
||||
version "1.30.1"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
|
||||
|
Reference in New Issue
Block a user