feature: various additions

This commit is contained in:
Jordan Knott 2020-06-12 17:21:58 -05:00
parent 4c02df9061
commit 6267a37b6e
72 changed files with 2038 additions and 389 deletions

3
api/Makefile Normal file
View File

@ -0,0 +1,3 @@
start:
docker container start test-db
go run cmd/citadel/main.go

View File

@ -74,6 +74,7 @@ type ComplexityRoot struct {
Mutation struct {
AddTaskLabel func(childComplexity int, input *AddTaskLabelInput) int
AssignTask func(childComplexity int, input *AssignTaskInput) int
ClearProfileAvatar func(childComplexity int) int
CreateProject func(childComplexity int, input NewProject) int
CreateProjectLabel func(childComplexity int, input NewProjectLabel) int
CreateRefreshToken func(childComplexity int, input NewRefreshToken) int
@ -94,6 +95,7 @@ type ComplexityRoot struct {
UpdateProjectName func(childComplexity int, input *UpdateProjectName) int
UpdateTaskDescription func(childComplexity int, input UpdateTaskDescriptionInput) int
UpdateTaskGroupLocation func(childComplexity int, input NewTaskGroupLocation) int
UpdateTaskGroupName func(childComplexity int, input UpdateTaskGroupName) int
UpdateTaskLocation func(childComplexity int, input NewTaskLocation) int
UpdateTaskName func(childComplexity int, input UpdateTaskName) int
}
@ -123,9 +125,8 @@ type ComplexityRoot struct {
}
ProjectMember struct {
FirstName func(childComplexity int) int
FullName func(childComplexity int) int
ID func(childComplexity int) int
LastName func(childComplexity int) int
ProfileIcon func(childComplexity int) int
}
@ -193,9 +194,9 @@ type ComplexityRoot struct {
UserAccount struct {
CreatedAt func(childComplexity int) int
Email func(childComplexity int) int
FirstName func(childComplexity int) int
FullName func(childComplexity int) int
ID func(childComplexity int) int
LastName func(childComplexity int) int
Initials func(childComplexity int) int
ProfileIcon func(childComplexity int) int
Username func(childComplexity int) int
}
@ -208,6 +209,7 @@ type MutationResolver interface {
CreateRefreshToken(ctx context.Context, input NewRefreshToken) (*pg.RefreshToken, error)
CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error)
CreateTeam(ctx context.Context, input NewTeam) (*pg.Team, error)
ClearProfileAvatar(ctx context.Context) (*pg.UserAccount, error)
CreateProject(ctx context.Context, input NewProject) (*pg.Project, error)
UpdateProjectName(ctx context.Context, input *UpdateProjectName) (*pg.Project, error)
CreateProjectLabel(ctx context.Context, input NewProjectLabel) (*pg.ProjectLabel, error)
@ -217,6 +219,7 @@ type MutationResolver interface {
UpdateProjectLabelColor(ctx context.Context, input UpdateProjectLabelColor) (*pg.ProjectLabel, error)
CreateTaskGroup(ctx context.Context, input NewTaskGroup) (*pg.TaskGroup, error)
UpdateTaskGroupLocation(ctx context.Context, input NewTaskGroupLocation) (*pg.TaskGroup, error)
UpdateTaskGroupName(ctx context.Context, input UpdateTaskGroupName) (*pg.TaskGroup, error)
DeleteTaskGroup(ctx context.Context, input DeleteTaskGroupInput) (*DeleteTaskGroupPayload, error)
AddTaskLabel(ctx context.Context, input *AddTaskLabelInput) (*pg.Task, error)
RemoveTaskLabel(ctx context.Context, input *RemoveTaskLabelInput) (*pg.Task, error)
@ -381,6 +384,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.AssignTask(childComplexity, args["input"].(*AssignTaskInput)), true
case "Mutation.clearProfileAvatar":
if e.complexity.Mutation.ClearProfileAvatar == nil {
break
}
return e.complexity.Mutation.ClearProfileAvatar(childComplexity), true
case "Mutation.createProject":
if e.complexity.Mutation.CreateProject == nil {
break
@ -621,6 +631,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Mutation.UpdateTaskGroupLocation(childComplexity, args["input"].(NewTaskGroupLocation)), true
case "Mutation.updateTaskGroupName":
if e.complexity.Mutation.UpdateTaskGroupName == nil {
break
}
args, err := ec.field_Mutation_updateTaskGroupName_args(context.TODO(), rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.UpdateTaskGroupName(childComplexity, args["input"].(UpdateTaskGroupName)), true
case "Mutation.updateTaskLocation":
if e.complexity.Mutation.UpdateTaskLocation == nil {
break
@ -750,12 +772,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ProjectLabel.Name(childComplexity), true
case "ProjectMember.firstName":
if e.complexity.ProjectMember.FirstName == nil {
case "ProjectMember.fullName":
if e.complexity.ProjectMember.FullName == nil {
break
}
return e.complexity.ProjectMember.FirstName(childComplexity), true
return e.complexity.ProjectMember.FullName(childComplexity), true
case "ProjectMember.id":
if e.complexity.ProjectMember.ID == nil {
@ -764,13 +786,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.ProjectMember.ID(childComplexity), true
case "ProjectMember.lastName":
if e.complexity.ProjectMember.LastName == nil {
break
}
return e.complexity.ProjectMember.LastName(childComplexity), true
case "ProjectMember.profileIcon":
if e.complexity.ProjectMember.ProfileIcon == nil {
break
@ -1071,12 +1086,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.UserAccount.Email(childComplexity), true
case "UserAccount.firstName":
if e.complexity.UserAccount.FirstName == nil {
case "UserAccount.fullName":
if e.complexity.UserAccount.FullName == nil {
break
}
return e.complexity.UserAccount.FirstName(childComplexity), true
return e.complexity.UserAccount.FullName(childComplexity), true
case "UserAccount.id":
if e.complexity.UserAccount.ID == nil {
@ -1085,12 +1100,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.UserAccount.ID(childComplexity), true
case "UserAccount.lastName":
if e.complexity.UserAccount.LastName == nil {
case "UserAccount.initials":
if e.complexity.UserAccount.Initials == nil {
break
}
return e.complexity.UserAccount.LastName(childComplexity), true
return e.complexity.UserAccount.Initials(childComplexity), true
case "UserAccount.profileIcon":
if e.complexity.UserAccount.ProfileIcon == nil {
@ -1172,6 +1187,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
var sources = []*ast.Source{
&ast.Source{Name: "graph/schema.graphqls", Input: `scalar Time
scalar UUID
scalar Upload
type ProjectLabel {
id: ID!
@ -1201,8 +1217,7 @@ type ProfileIcon {
type ProjectMember {
id: ID!
firstName: String!
lastName: String!
fullName: String!
profileIcon: ProfileIcon!
}
@ -1217,8 +1232,8 @@ type UserAccount {
id: ID!
email: String!
createdAt: Time!
firstName: String!
lastName: String!
fullName: String!
initials: String!
username: String!
profileIcon: ProfileIcon!
}
@ -1295,8 +1310,8 @@ input NewRefreshToken {
input NewUserAccount {
username: String!
email: String!
firstName: String!
lastName: String!
fullName: String!
initials: String!
password: String!
}
@ -1427,12 +1442,19 @@ type UpdateTaskLocationPayload {
previousTaskGroupID: UUID!
task: Task!
}
input UpdateTaskGroupName {
taskGroupID: UUID!
name: String!
}
type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): UserAccount!
createTeam(input: NewTeam!): Team!
clearProfileAvatar: UserAccount!
createProject(input: NewProject!): Project!
updateProjectName(input: UpdateProjectName): Project!
@ -1445,6 +1467,7 @@ type Mutation {
createTaskGroup(input: NewTaskGroup!): TaskGroup!
updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup!
updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup!
deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload!
addTaskLabel(input: AddTaskLabelInput): Task!
@ -1777,6 +1800,20 @@ func (ec *executionContext) field_Mutation_updateTaskGroupLocation_args(ctx cont
return args, nil
}
func (ec *executionContext) field_Mutation_updateTaskGroupName_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
var arg0 UpdateTaskGroupName
if tmp, ok := rawArgs["input"]; ok {
arg0, err = ec.unmarshalNUpdateTaskGroupName2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskGroupName(ctx, tmp)
if err != nil {
return nil, err
}
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_updateTaskLocation_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
var err error
args := map[string]interface{}{}
@ -2306,6 +2343,40 @@ func (ec *executionContext) _Mutation_createTeam(ctx context.Context, field grap
return ec.marshalNTeam2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTeam(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_clearProfileAvatar(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().ClearProfileAvatar(rctx)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*pg.UserAccount)
fc.Result = res
return ec.marshalNUserAccount2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐUserAccount(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -2675,6 +2746,47 @@ func (ec *executionContext) _Mutation_updateTaskGroupLocation(ctx context.Contex
return ec.marshalNTaskGroup2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTaskGroup(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_updateTaskGroupName(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
rawArgs := field.ArgumentMap(ec.Variables)
args, err := ec.field_Mutation_updateTaskGroupName_args(ctx, rawArgs)
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
fc.Args = args
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().UpdateTaskGroupName(rctx, args["input"].(UpdateTaskGroupName))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*pg.TaskGroup)
fc.Result = res
return ec.marshalNTaskGroup2ᚖgithubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋpgᚐTaskGroup(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_deleteTaskGroup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -3699,7 +3811,7 @@ func (ec *executionContext) _ProjectMember_id(ctx context.Context, field graphql
return ec.marshalNID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res)
}
func (ec *executionContext) _ProjectMember_firstName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) {
func (ec *executionContext) _ProjectMember_fullName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
@ -3716,41 +3828,7 @@ func (ec *executionContext) _ProjectMember_firstName(ctx context.Context, field
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.FirstName, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) _ProjectMember_lastName(ctx context.Context, field graphql.CollectedField, obj *ProjectMember) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "ProjectMember",
Field: field,
Args: nil,
IsMethod: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.LastName, nil
return obj.FullName, nil
})
if err != nil {
ec.Error(ctx, err)
@ -5255,7 +5333,7 @@ func (ec *executionContext) _UserAccount_createdAt(ctx context.Context, field gr
return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) {
func (ec *executionContext) _UserAccount_fullName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
@ -5272,7 +5350,7 @@ func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field gr
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.FirstName, nil
return obj.FullName, nil
})
if err != nil {
ec.Error(ctx, err)
@ -5289,7 +5367,7 @@ func (ec *executionContext) _UserAccount_firstName(ctx context.Context, field gr
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) _UserAccount_lastName(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) {
func (ec *executionContext) _UserAccount_initials(ctx context.Context, field graphql.CollectedField, obj *pg.UserAccount) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
@ -5306,7 +5384,7 @@ func (ec *executionContext) _UserAccount_lastName(ctx context.Context, field gra
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.LastName, nil
return obj.Initials, nil
})
if err != nil {
ec.Error(ctx, err)
@ -6854,15 +6932,15 @@ func (ec *executionContext) unmarshalInputNewUserAccount(ctx context.Context, ob
if err != nil {
return it, err
}
case "firstName":
case "fullName":
var err error
it.FirstName, err = ec.unmarshalNString2string(ctx, v)
it.FullName, err = ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
case "lastName":
case "initials":
var err error
it.LastName, err = ec.unmarshalNString2string(ctx, v)
it.Initials, err = ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
@ -7088,6 +7166,30 @@ func (ec *executionContext) unmarshalInputUpdateTaskDescriptionInput(ctx context
return it, nil
}
func (ec *executionContext) unmarshalInputUpdateTaskGroupName(ctx context.Context, obj interface{}) (UpdateTaskGroupName, error) {
var it UpdateTaskGroupName
var asMap = obj.(map[string]interface{})
for k, v := range asMap {
switch k {
case "taskGroupID":
var err error
it.TaskGroupID, err = ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v)
if err != nil {
return it, err
}
case "name":
var err error
it.Name, err = ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputUpdateTaskName(ctx context.Context, obj interface{}) (UpdateTaskName, error) {
var it UpdateTaskName
var asMap = obj.(map[string]interface{})
@ -7265,6 +7367,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
invalids++
}
case "clearProfileAvatar":
out.Values[i] = ec._Mutation_clearProfileAvatar(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "createProject":
out.Values[i] = ec._Mutation_createProject(ctx, field)
if out.Values[i] == graphql.Null {
@ -7310,6 +7417,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
invalids++
}
case "updateTaskGroupName":
out.Values[i] = ec._Mutation_updateTaskGroupName(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "deleteTaskGroup":
out.Values[i] = ec._Mutation_deleteTaskGroup(ctx, field)
if out.Values[i] == graphql.Null {
@ -7607,13 +7719,8 @@ func (ec *executionContext) _ProjectMember(ctx context.Context, sel ast.Selectio
if out.Values[i] == graphql.Null {
invalids++
}
case "firstName":
out.Values[i] = ec._ProjectMember_firstName(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
case "lastName":
out.Values[i] = ec._ProjectMember_lastName(ctx, field, obj)
case "fullName":
out.Values[i] = ec._ProjectMember_fullName(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
@ -8223,13 +8330,13 @@ func (ec *executionContext) _UserAccount(ctx context.Context, sel ast.SelectionS
if out.Values[i] == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
case "firstName":
out.Values[i] = ec._UserAccount_firstName(ctx, field, obj)
case "fullName":
out.Values[i] = ec._UserAccount_fullName(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
case "lastName":
out.Values[i] = ec._UserAccount_lastName(ctx, field, obj)
case "initials":
out.Values[i] = ec._UserAccount_initials(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&invalids, 1)
}
@ -9158,6 +9265,10 @@ func (ec *executionContext) unmarshalNUpdateTaskDescriptionInput2githubᚗcomᚋ
return ec.unmarshalInputUpdateTaskDescriptionInput(ctx, v)
}
func (ec *executionContext) unmarshalNUpdateTaskGroupName2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskGroupName(ctx context.Context, v interface{}) (UpdateTaskGroupName, error) {
return ec.unmarshalInputUpdateTaskGroupName(ctx, v)
}
func (ec *executionContext) marshalNUpdateTaskLocationPayload2githubᚗcomᚋjordanknottᚋprojectᚑcitadelᚋapiᚋgraphᚐUpdateTaskLocationPayload(ctx context.Context, sel ast.SelectionSet, v UpdateTaskLocationPayload) graphql.Marshaler {
return ec._UpdateTaskLocationPayload(ctx, sel, &v)
}

View File

@ -102,8 +102,8 @@ type NewTeam struct {
type NewUserAccount struct {
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
FullName string `json:"fullName"`
Initials string `json:"initials"`
Password string `json:"password"`
}
@ -115,8 +115,7 @@ type ProfileIcon struct {
type ProjectMember struct {
ID uuid.UUID `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
FullName string `json:"fullName"`
ProfileIcon *ProfileIcon `json:"profileIcon"`
}
@ -169,6 +168,11 @@ type UpdateTaskDescriptionInput struct {
Description string `json:"description"`
}
type UpdateTaskGroupName struct {
TaskGroupID uuid.UUID `json:"taskGroupID"`
Name string `json:"name"`
}
type UpdateTaskLocationPayload struct {
PreviousTaskGroupID uuid.UUID `json:"previousTaskGroupID"`
Task *pg.Task `json:"task"`

View File

@ -1,5 +1,6 @@
scalar Time
scalar UUID
scalar Upload
type ProjectLabel {
id: ID!
@ -29,8 +30,7 @@ type ProfileIcon {
type ProjectMember {
id: ID!
firstName: String!
lastName: String!
fullName: String!
profileIcon: ProfileIcon!
}
@ -45,8 +45,8 @@ type UserAccount {
id: ID!
email: String!
createdAt: Time!
firstName: String!
lastName: String!
fullName: String!
initials: String!
username: String!
profileIcon: ProfileIcon!
}
@ -123,8 +123,8 @@ input NewRefreshToken {
input NewUserAccount {
username: String!
email: String!
firstName: String!
lastName: String!
fullName: String!
initials: String!
password: String!
}
@ -255,12 +255,19 @@ type UpdateTaskLocationPayload {
previousTaskGroupID: UUID!
task: Task!
}
input UpdateTaskGroupName {
taskGroupID: UUID!
name: String!
}
type Mutation {
createRefreshToken(input: NewRefreshToken!): RefreshToken!
createUserAccount(input: NewUserAccount!): UserAccount!
createTeam(input: NewTeam!): Team!
clearProfileAvatar: UserAccount!
createProject(input: NewProject!): Project!
updateProjectName(input: UpdateProjectName): Project!
@ -273,6 +280,7 @@ type Mutation {
createTaskGroup(input: NewTaskGroup!): TaskGroup!
updateTaskGroupLocation(input: NewTaskGroupLocation!): TaskGroup!
updateTaskGroupName(input: UpdateTaskGroupName!): TaskGroup!
deleteTaskGroup(input: DeleteTaskGroupInput!): DeleteTaskGroupPayload!
addTaskLabel(input: AddTaskLabelInput): Task!

View File

@ -29,7 +29,14 @@ func (r *mutationResolver) CreateRefreshToken(ctx context.Context, input NewRefr
func (r *mutationResolver) CreateUserAccount(ctx context.Context, input NewUserAccount) (*pg.UserAccount, error) {
createdAt := time.Now().UTC()
userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{input.FirstName, input.LastName, input.Email, input.Username, createdAt, input.Password})
userAccount, err := r.Repository.CreateUserAccount(ctx, pg.CreateUserAccountParams{
FullName: input.FullName,
Initials: input.Initials,
Email: input.Email,
Username: input.Username,
CreatedAt: createdAt,
PasswordHash: input.Password,
})
return &userAccount, err
}
@ -43,6 +50,21 @@ func (r *mutationResolver) CreateTeam(ctx context.Context, input NewTeam) (*pg.T
return &team, err
}
func (r *mutationResolver) ClearProfileAvatar(ctx context.Context) (*pg.UserAccount, error) {
userID, ok := GetUserID(ctx)
if !ok {
return &pg.UserAccount{}, fmt.Errorf("internal server error")
}
log.WithFields(log.Fields{
"userID": userID,
}).Info("getting user account")
user, err := r.Repository.UpdateUserAccountProfileAvatarURL(ctx, pg.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{Valid: false, String: ""}})
if err != nil {
return &pg.UserAccount{}, err
}
return &user, nil
}
func (r *mutationResolver) CreateProject(ctx context.Context, input NewProject) (*pg.Project, error) {
createdAt := time.Now().UTC()
project, err := r.Repository.CreateProject(ctx, pg.CreateProjectParams{input.UserID, input.TeamID, createdAt, input.Name})
@ -120,6 +142,10 @@ func (r *mutationResolver) UpdateTaskGroupLocation(ctx context.Context, input Ne
return &taskGroup, err
}
func (r *mutationResolver) UpdateTaskGroupName(ctx context.Context, input UpdateTaskGroupName) (*pg.TaskGroup, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *mutationResolver) DeleteTaskGroup(ctx context.Context, input DeleteTaskGroupInput) (*DeleteTaskGroupPayload, error) {
deletedTasks, err := r.Repository.DeleteTasksByTaskGroupID(ctx, input.TaskGroupID)
if err != nil {
@ -301,9 +327,12 @@ func (r *projectResolver) Owner(ctx context.Context, obj *pg.Project) (*ProjectM
if err != nil {
return &ProjectMember{}, err
}
initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0])
profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor}
return &ProjectMember{obj.Owner, user.FirstName, user.LastName, profileIcon}, nil
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
return &ProjectMember{obj.Owner, user.FullName, profileIcon}, nil
}
func (r *projectResolver) TaskGroups(ctx context.Context, obj *pg.Project) ([]pg.TaskGroup, error) {
@ -316,9 +345,12 @@ func (r *projectResolver) Members(ctx context.Context, obj *pg.Project) ([]Proje
if err != nil {
return members, err
}
initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0])
profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor}
members = append(members, ProjectMember{obj.Owner, user.FirstName, user.LastName, profileIcon})
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
members = append(members, ProjectMember{obj.Owner, user.FullName, profileIcon})
return members, nil
}
@ -463,9 +495,12 @@ func (r *taskResolver) Assigned(ctx context.Context, obj *pg.Task) ([]ProjectMem
if err != nil {
return taskMembers, err
}
initials := string([]rune(user.FirstName)[0]) + string([]rune(user.LastName)[0])
profileIcon := &ProfileIcon{nil, &initials, &user.ProfileBgColor}
taskMembers = append(taskMembers, ProjectMember{taskMemberLink.UserID, user.FirstName, user.LastName, profileIcon})
var url *string
if user.ProfileAvatarUrl.Valid {
url = &user.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &user.Initials, &user.ProfileBgColor}
taskMembers = append(taskMembers, ProjectMember{taskMemberLink.UserID, user.FullName, profileIcon})
}
return taskMembers, nil
}
@ -505,8 +540,11 @@ func (r *userAccountResolver) ID(ctx context.Context, obj *pg.UserAccount) (uuid
}
func (r *userAccountResolver) ProfileIcon(ctx context.Context, obj *pg.UserAccount) (*ProfileIcon, error) {
initials := string([]rune(obj.FirstName)[0]) + string([]rune(obj.LastName)[0])
profileIcon := &ProfileIcon{nil, &initials, &obj.ProfileBgColor}
var url *string
if obj.ProfileAvatarUrl.Valid {
url = &obj.ProfileAvatarUrl.String
}
profileIcon := &ProfileIcon{url, &obj.Initials, &obj.ProfileBgColor}
return profileIcon, nil
}
@ -554,39 +592,3 @@ type taskGroupResolver struct{ *Resolver }
type taskLabelResolver struct{ *Resolver }
type teamResolver struct{ *Resolver }
type userAccountResolver struct{ *Resolver }
// !!! WARNING !!!
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
// one last chance to move it out of harms way if you want. There are two reasons this happens:
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
// it when you're done.
// - You have helper methods in this file. Move them out to keep these resolver files clean.
func (r *taskLabelResolver) ColorHex(ctx context.Context, obj *pg.TaskLabel) (string, error) {
projectLabel, err := r.Repository.GetProjectLabelByID(ctx, obj.ProjectLabelID)
if err != nil {
return "", err
}
labelColor, err := r.Repository.GetLabelColorByID(ctx, projectLabel.LabelColorID)
if err != nil {
return "", err
}
return labelColor.ColorHex, nil
}
func (r *taskLabelResolver) Name(ctx context.Context, obj *pg.TaskLabel) (*string, error) {
projectLabel, err := r.Repository.GetProjectLabelByID(ctx, obj.ProjectLabelID)
if err != nil {
return nil, err
}
name := projectLabel.Name
if !name.Valid {
return nil, err
}
return &name.String, err
}
func (r *projectLabelResolver) ColorHex(ctx context.Context, obj *pg.ProjectLabel) (string, error) {
labelColor, err := r.Repository.GetLabelColorByID(ctx, obj.LabelColorID)
if err != nil {
return "", err
}
return labelColor.ColorHex, nil
}

View File

@ -0,0 +1,3 @@
ALTER TABLE user_account ADD COLUMN full_name TEXT;
UPDATE user_account SET full_name = CONCAT(first_name, ' ', last_name);
ALTER TABLE user_account ALTER COLUMN full_name SET NOT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE user_account DROP COLUMN first_name;

View File

@ -0,0 +1 @@
ALTER TABLE user_account DROP COLUMN last_name;

View File

@ -0,0 +1 @@
ALTER TABLE user_account ADD COLUMN initials TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1 @@
ALTER TABLE user_account ADD COLUMN profile_avatar_url TEXT;

View File

@ -87,10 +87,11 @@ type Team struct {
type UserAccount struct {
UserID uuid.UUID `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
ProfileBgColor string `json:"profile_bg_color"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
}

View File

@ -17,6 +17,7 @@ type Repository interface {
GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error)
GetProjectByID(ctx context.Context, projectID uuid.UUID) (Project, error)
UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error)
CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error)
GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error)
GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error)

View File

@ -64,6 +64,7 @@ type Querier interface {
UpdateTaskGroupLocation(ctx context.Context, arg UpdateTaskGroupLocationParams) (TaskGroup, error)
UpdateTaskLocation(ctx context.Context, arg UpdateTaskLocationParams) (Task, error)
UpdateTaskName(ctx context.Context, arg UpdateTaskNameParams) (Task, error)
UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error)
}
var _ Querier = (*Queries)(nil)

View File

@ -5,20 +5,20 @@ package pg
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const createUserAccount = `-- name: CreateUserAccount :one
INSERT INTO user_account(first_name, last_name, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url
`
type CreateUserAccountParams struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
FullName string `json:"full_name"`
Initials string `json:"initials"`
Email string `json:"email"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
@ -27,8 +27,8 @@ type CreateUserAccountParams struct {
func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountParams) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, createUserAccount,
arg.FirstName,
arg.LastName,
arg.FullName,
arg.Initials,
arg.Email,
arg.Username,
arg.CreatedAt,
@ -38,18 +38,19 @@ func (q *Queries) CreateUserAccount(ctx context.Context, arg CreateUserAccountPa
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
)
return i, err
}
const getAllUserAccounts = `-- name: GetAllUserAccounts :many
SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account
`
func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) {
@ -64,12 +65,13 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
if err := rows.Scan(
&i.UserID,
&i.CreatedAt,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
); err != nil {
return nil, err
}
@ -85,7 +87,7 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error)
}
const getUserAccountByID = `-- name: GetUserAccountByID :one
SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account WHERE user_id = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account WHERE user_id = $1
`
func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (UserAccount, error) {
@ -94,18 +96,19 @@ func (q *Queries) GetUserAccountByID(ctx context.Context, userID uuid.UUID) (Use
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
)
return i, err
}
const getUserAccountByUsername = `-- name: GetUserAccountByUsername :one
SELECT user_id, created_at, first_name, last_name, email, username, password_hash, profile_bg_color FROM user_account WHERE username = $1
SELECT user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url FROM user_account WHERE username = $1
`
func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string) (UserAccount, error) {
@ -114,12 +117,40 @@ func (q *Queries) GetUserAccountByUsername(ctx context.Context, username string)
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.FirstName,
&i.LastName,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
)
return i, err
}
const updateUserAccountProfileAvatarURL = `-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING user_id, created_at, email, username, password_hash, profile_bg_color, full_name, initials, profile_avatar_url
`
type UpdateUserAccountProfileAvatarURLParams struct {
UserID uuid.UUID `json:"user_id"`
ProfileAvatarUrl sql.NullString `json:"profile_avatar_url"`
}
func (q *Queries) UpdateUserAccountProfileAvatarURL(ctx context.Context, arg UpdateUserAccountProfileAvatarURLParams) (UserAccount, error) {
row := q.db.QueryRowContext(ctx, updateUserAccountProfileAvatarURL, arg.UserID, arg.ProfileAvatarUrl)
var i UserAccount
err := row.Scan(
&i.UserID,
&i.CreatedAt,
&i.Email,
&i.Username,
&i.PasswordHash,
&i.ProfileBgColor,
&i.FullName,
&i.Initials,
&i.ProfileAvatarUrl,
)
return i, err
}

View File

@ -11,8 +11,12 @@ SELECT * FROM task_group;
-- name: GetTaskGroupByID :one
SELECT * FROM task_group WHERE task_group_id = $1;
-- name: UpdateTaskGroupLocation :one
UPDATE task_group SET position = $2 WHERE task_group_id = $1 RETURNING *;
-- name: SetTaskGroupName :one
UPDATE task_group SET name = $2 WHERE task_group_id = $1 RETURNING *;
-- name: DeleteTaskGroupByID :execrows
DELETE FROM task_group WHERE task_group_id = $1;
-- name: UpdateTaskGroupLocation :one
UPDATE task_group SET position = $2 WHERE task_group_id = $1 RETURNING *;

View File

@ -8,6 +8,9 @@ SELECT * FROM user_account;
SELECT * FROM user_account WHERE username = $1;
-- name: CreateUserAccount :one
INSERT INTO user_account(first_name, last_name, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
INSERT INTO user_account(full_name, initials, email, username, created_at, password_hash)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;
-- name: UpdateUserAccountProfileAvatarURL :one
UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1
RETURNING *;

View File

@ -24,6 +24,7 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
accessClaims, err := ValidateAccessToken(accessTokenString)
if err != nil {
if _, ok := err.(*ErrExpiredToken); ok {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{
"data": {},
"errors": [

View File

@ -30,3 +30,8 @@ type LogoutResponseData struct {
type RefreshTokenResponseData struct {
AccessToken string `json:"accessToken"`
}
type AvatarUploadResponseData struct {
UserID string `json:"userID"`
URL string `json:"url"`
}

View File

@ -1,12 +1,16 @@
package router
import (
"database/sql"
"encoding/json"
"io/ioutil"
"net/http"
"time"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/cors"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
log "github.com/sirupsen/logrus"
@ -18,6 +22,45 @@ func (h *CitadelHandler) PingHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
}
func (h *CitadelHandler) ProfileImageUpload(w http.ResponseWriter, r *http.Request) {
log.Info("preparing to upload file")
userID, ok := r.Context().Value("userID").(uuid.UUID)
if !ok {
log.Error("not a valid uuid")
w.WriteHeader(http.StatusInternalServerError)
return
}
// Parse our multipart form, 10 << 20 specifies a maximum
// upload of 10 MB files.
r.ParseMultipartForm(10 << 20)
file, handler, err := r.FormFile("file")
if err != nil {
log.WithError(err).Error("issue while uploading file")
return
}
defer file.Close()
log.WithFields(log.Fields{"filename": handler.Filename, "size": handler.Size, "header": handler.Header}).Info("file metadata")
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
log.WithError(err).Error("while reading file")
return
}
err = ioutil.WriteFile("uploads/"+handler.Filename, fileBytes, 0644)
if err != nil {
log.WithError(err).Error("while reading file")
return
}
h.repo.UpdateUserAccountProfileAvatarURL(r.Context(), pg.UpdateUserAccountProfileAvatarURLParams{UserID: userID, ProfileAvatarUrl: sql.NullString{String: "http://localhost:3333/uploads/" + handler.Filename, Valid: true}})
// return that we have successfully uploaded our file!
log.Info("file uploaded")
json.NewEncoder(w).Encode(AvatarUploadResponseData{URL: "http://localhost:3333/uploads/" + handler.Filename, UserID: userID.String()})
}
func NewRouter(db *sqlx.DB) (chi.Router, error) {
formatter := new(log.TextFormatter)
formatter.TimestampFormat = "02-01-2006 15:04:05"
@ -50,9 +93,13 @@ func NewRouter(db *sqlx.DB) (chi.Router, error) {
r.Group(func(mux chi.Router) {
mux.Mount("/auth", authResource{}.Routes(citadelHandler))
mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql"))
var imgServer = http.FileServer(http.Dir("./uploads/"))
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
})
r.Group(func(mux chi.Router) {
mux.Use(AuthenticationMiddleware)
mux.Post("/users/me/avatar", citadelHandler.ProfileImageUpload)
mux.Get("/ping", citadelHandler.PingHandler)
mux.Handle("/graphql", graph.NewHandler(repository))
})

View File

@ -43,6 +43,10 @@ func ValidateAccessToken(accessTokenString string) (AccessTokenClaims, error) {
return jwtKey, nil
})
if err != nil {
return *accessClaims, nil
}
if accessToken.Valid {
log.WithFields(log.Fields{
"token": accessTokenString,

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="256px" height="315px" viewBox="0 0 256 315" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<g transform="translate(0.000000, 281.859985)" fill="#1A1918">
<path d="M67.516834,32.0035248 C66.9753105,32.0035248 66.3906671,31.8666739 65.9847187,31.3176926 L48.6011921,8.45648614 L48.6011921,31.775177 L44.6368825,31.775177 L44.6368825,3.06093065 C44.6368825,1.91840275 45.5828006,1.00382829 46.6646822,1.00382829 C47.2943879,1.00382829 47.8355229,1.18642767 48.239529,1.73540898 L65.5791587,24.5512613 L65.5791587,1.323673 L69.5430798,1.323673 L69.5430798,29.9917765 C69.5430798,31.1808416 68.5971618,32.0035248 67.516834,32.0035248"></path>
<path d="M100.306277,32.277976 C96.3435217,32.277976 92.1993517,31.4545041 88.3259436,29.9921314 L89.2722501,26.6994268 C92.8302228,27.8427434 96.7032425,28.665821 100.306277,28.665821 C105.305853,28.665821 107.917907,26.8824205 107.917907,24.3682281 C107.917907,22.0815948 105.890496,20.9386725 98.9097372,17.6463622 C91.9289785,14.3544463 89.1825141,12.2515955 89.1825141,8.13581322 C89.1825141,2.9701831 93.1452698,0.865754758 100.620936,0.865754758 C103.729063,0.865754758 108.502551,1.50662733 111.250957,2.32970491 L110.620086,5.71272332 C107.601695,4.98153702 103.819576,4.47869854 100.711449,4.47869854 C95.4430543,4.47869854 93.2808449,5.48398111 93.2808449,7.99935666 C93.2808449,10.4678006 94.8564686,11.5196204 102.242787,14.949176 C110.034666,18.561331 112.016238,20.39048 112.016238,24.1390915 C112.016238,29.5807898 106.881088,32.277976 100.306277,32.277976"></path>
<path d="M130.798131,1.323673 L134.896461,1.323673 L134.896461,31.7747826 L130.798131,31.7747826 L130.798131,1.323673 Z"></path>
<path d="M169.757834,17.9669168 L159.578047,17.9669168 L159.578047,28.3009377 L169.757834,28.3009377 C173.225294,28.3009377 175.342053,26.1058013 175.342053,23.0418388 C175.342053,20.1612645 173.135558,17.9669168 169.757834,17.9669168 M167.775097,4.79846437 L159.578047,4.79846437 L159.578047,14.5377556 L167.775097,14.5377556 C171.063473,14.5377556 173.450217,12.434116 173.450217,9.50818761 C173.450217,6.62761329 171.378132,4.79846437 167.775097,4.79846437 M169.802508,31.7750587 L157.552578,31.7750587 C156.38096,31.7750587 155.480104,30.8600898 155.480104,29.7175619 L155.480104,3.38144581 C155.480104,2.2834832 156.38096,1.32394907 157.552578,1.32394907 L167.685749,1.32394907 C173.900839,1.32394907 177.59361,4.38712277 177.59361,9.1883429 C177.59361,11.7940322 176.062271,14.2175165 173.719813,15.6349294 C177.189215,16.6867492 179.529731,19.5669291 179.529731,23.0418388 C179.529731,27.888413 175.522302,31.7750587 169.802508,31.7750587"></path>
<path d="M199.212246,31.7750981 C198.088022,31.7750981 197.186778,30.8147752 197.186778,29.7176013 L197.186778,1.32398851 L201.284331,1.32398851 L201.284331,28.0718406 L217.588307,28.0718406 L217.588307,31.7750981 L199.212246,31.7750981 Z"></path>
<path d="M245.526181,32.277976 C237.148883,32.277976 232.104634,29.1690539 232.104634,22.9050667 C232.104634,19.9329955 233.771547,17.0981697 237.014473,15.7722536 C234.40203,14.1260985 233.051329,11.7941111 233.051329,9.23377583 C233.051329,3.56451846 237.421198,0.865754758 245.617083,0.865754758 C248.364713,0.865754758 252.237344,1.18638824 255.480658,1.91836331 L255.029647,5.30138173 C251.607638,4.75240042 248.408609,4.43334448 245.301259,4.43334448 C239.986637,4.43334448 237.148883,5.89571709 237.148883,9.4167696 C237.148883,12.2062414 239.761714,14.4005891 243.230339,14.4005891 L250.255383,14.4005891 C251.246364,14.4005891 252.011644,15.1779182 252.011644,16.1378468 C252.011644,17.1435237 251.246364,17.9208529 250.255383,17.9208529 L242.69037,17.9208529 C238.725672,17.9208529 236.204907,19.8864583 236.204907,22.9050667 C236.204907,27.3399049 240.303238,28.665821 245.437222,28.665821 C248.229914,28.665821 252.011644,28.3010166 255.073933,27.5690415 L255.660907,30.9063115 C252.73264,31.7297835 248.903905,32.277976 245.526181,32.277976"></path>
<path d="M14.1379982,7.03469555 L21.3157099,25.0189603 L10.4751393,16.3496305 L14.1379982,7.03469555 Z M26.8894398,29.1623494 L15.8495854,2.18772698 C15.5345384,1.41039785 14.9036674,0.99866187 14.1379982,0.99866187 C13.372329,0.99866187 12.6967842,1.41039785 12.3817372,2.18772698 L0.263885888,31.7751375 L4.40844431,31.7751375 L9.20601639,19.5768676 L23.5202622,31.3176531 C24.0959709,31.7897297 24.5112425,32.0034854 25.0523775,32.0034854 C26.1327053,32.0034854 27.0786234,31.1808022 27.0786234,29.9921314 C27.0786234,29.7992781 27.0106416,29.492448 26.8894398,29.1623494 L26.8894398,29.1623494 Z"></path>
</g>
<g>
<path d="M255.878566,127.868028 C255.878566,198.323171 198.768494,255.43274 128.312347,255.43274 C57.8622265,255.43274 0.746127325,198.323171 0.746127325,127.868028 C0.746127325,57.4179077 57.8622265,0.301808544 128.312347,0.301808544 C198.768494,0.301808544 255.878566,57.4179077 255.878566,127.868028" fill="#1A1918"></path>
<path d="M130.459863,78.228885 L163.47146,159.705138 L113.608007,120.427335 L130.459863,78.228885 Z M189.104342,178.474123 L138.32577,56.2720498 C136.876246,52.7476854 133.977698,50.8827909 130.459863,50.8827909 C126.935499,50.8827909 123.826002,52.7476854 122.376477,56.2720498 L66.6436045,190.312411 L85.708924,190.312411 L107.771234,135.047143 L173.610097,188.23707 C176.258016,190.378208 178.168617,191.346566 180.652297,191.346566 C185.626186,191.346566 189.973756,187.617782 189.973756,182.235555 C189.973756,181.359612 189.664363,179.969354 189.104342,178.474123 L189.104342,178.474123 Z" fill="#FFFFFF"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
api/uploads/headshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

BIN
api/uploads/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

91
api/uploads/logo.svg Normal file
View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="300"
height="300"
viewBox="0 0 300.00001 300.00001"
id="svg4144"
version="1.1"
inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="logo.svg"
inkscape:export-filename="/home/jordan/blog/static/images/logo.png"
inkscape:export-xdpi="12.6"
inkscape:export-ydpi="12.6">
<defs
id="defs4146">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 531.49605 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="1062.9921 : 531.49605 : 1"
inkscape:persp3d-origin="531.49605 : 354.3307 : 1"
id="perspective4164" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="197.58796"
inkscape:cy="137.75021"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="995"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
units="px" />
<metadata
id="metadata4149">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-752.36208)">
<circle
style="opacity:0.98000004;fill:none;fill-opacity:1;stroke:#e136ed;stroke-width:13.443;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4692"
cx="150"
cy="902.36206"
r="129.61052"
inkscape:export-xdpi="3.7273829"
inkscape:export-ydpi="3.7273829" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;line-height:0%;font-family:'Techno Hideo';-inkscape-font-specification:'Techno Hideo';letter-spacing:0px;word-spacing:0px;fill:#e136ed;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;"
x="85.248688"
y="796.60187"
id="text4184"
transform="scale(0.83194405,1.2020039)"
inkscape:export-xdpi="3.7273829"
inkscape:export-ydpi="3.7273829"><tspan
sodipodi:role="line"
id="tspan4186"
x="85.248688"
y="796.60187"
style="font-size:181.42245483px;line-height:1.25;fill:#e136ed;fill-opacity:1;">JK</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
api/uploads/text4184.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -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
View File

@ -0,0 +1,3 @@
start:
yarn start

View File

@ -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",

View File

@ -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} />
<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>
);

View File

@ -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}
/>
)}
</>
);
};

View File

@ -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>
</>
)}
</Router>

71
web/src/Profile/index.tsx Normal file
View 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;

View File

@ -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(

View File

@ -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({

View File

@ -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,

View File

@ -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$;

View 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 />
</>
);
};

View 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;

View File

@ -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>

View File

@ -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;

View File

@ -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,6 +20,8 @@ export default {
export const Default = () => {
return (
<PopupWrapper>
<Popup title={null} tab={0}>
<DueDateManager
task={{
id: '1',
@ -43,13 +50,14 @@ export const Default = () => {
{
id: '1',
profileIcon: { url: null, initials: null, bgColor: null },
firstName: 'Jordan',
lastName: 'Knott',
fullName: 'Jordan Knott',
},
],
}}
onCancel={action('cancel')}
onDueDateChange={action('due date change')}
/>
</Popup>
</PopupWrapper>
);
};

View File

@ -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`

View File

@ -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>

View File

@ -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;

View File

@ -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')}

View File

@ -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}

View File

@ -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);
`;

View File

@ -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)}>

View File

@ -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" />

View File

@ -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',
},
],
}}

View 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;

View File

@ -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')};
`;

View 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')}
/>
</>
);
};

View 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;

View 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 />
</>
);
};

View File

@ -0,0 +1,8 @@
import React from 'react';
import styled from 'styled-components';
const Tabs = () => {
return <span>HEllo!</span>;
};
export default Tabs;

View File

@ -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>
);
};

View File

@ -55,8 +55,7 @@ export const Default = () => {
{
id: '1',
profileIcon: { bgColor: null, url: null, initials: null },
firstName: 'Jordan',
lastName: 'Knott',
fullName: 'Jordan Knott',
},
],
}}

View File

@ -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`

View File

@ -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}
/>
)}
</>
);
};

View File

@ -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>
{user && (
<ProfileContainer>
<ProfileNameWrapper>
<ProfileNamePrimary>{`${firstName} ${lastName}`}</ProfileNamePrimary>
<ProfileNamePrimary>{user.fullName}</ProfileNamePrimary>
<ProfileNameSecondary>Manager</ProfileNameSecondary>
</ProfileNameWrapper>
<ProfileIcon ref={$profileRef} onClick={handleProfileClick} bgColor={bgColor}>
{initials}
</ProfileIcon>
<ProfileIcon user={user} size={40} onProfileClick={handleProfileClick} />}
</ProfileContainer>
)}
</GlobalActions>
</NavbarHeader>
</NavbarWrapper>

View File

@ -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
}
}
`;

View File

@ -3,8 +3,7 @@ mutation assignTask($taskID: UUID!, $userID: UUID!) {
id
assigned {
id
firstName
lastName
fullName
}
}
}

View File

@ -0,0 +1,11 @@
mutation clearProfileAvatar {
clearProfileAvatar {
id
fullName
profileIcon {
initials
bgColor
url
}
}
}

View File

@ -26,8 +26,7 @@ mutation createTask($taskGroupID: String!, $name: String!, $position: Float!) {
}
assigned {
id
firstName
lastName
fullName
profileIcon {
url
initials

View File

@ -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

View File

@ -24,8 +24,7 @@ query findTask($taskID: UUID!) {
}
assigned {
id
firstName
lastName
fullName
profileIcon {
url
initials

View File

@ -1,10 +1,11 @@
query me {
me {
firstName
lastName
id
fullName
profileIcon {
initials
bgColor
url
}
}
}

View File

@ -2,8 +2,7 @@ mutation unassignTask($taskID: UUID!, $userID: UUID!) {
unassignTask(input: {taskID: $taskID, userID: $userID}) {
assigned {
id
firstName
lastName
fullName
}
id
}

View File

@ -1,5 +1,6 @@
mutation updateTaskDescription($taskID: UUID!, $description: String!) {
updateTaskDescription(input: {taskID: $taskID, description: $description}) {
id
description
}
}

View File

@ -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"