Skip to content
GitLab
Projects
Groups
Snippets
Help
Loading...
Help
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
S
scratch-www
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Analytics
Analytics
Repository
Value Stream
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Commits
Open sidebar
xpstem
scratch-www
Commits
29f6006b
Commit
29f6006b
authored
Apr 29, 2021
by
Paul Kaplan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Add functionality for adding/removing projects and managing curators
parent
4f54e14e
Changes
15
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
983 additions
and
122 deletions
+983
-122
src/redux/studio-permissions.js
src/redux/studio-permissions.js
+16
-1
src/redux/studio.js
src/redux/studio.js
+1
-0
src/views/studio/lib/fetchers.js
src/views/studio/lib/fetchers.js
+2
-21
src/views/studio/lib/studio-member-actions.js
src/views/studio/lib/studio-member-actions.js
+175
-0
src/views/studio/lib/studio-project-actions.js
src/views/studio/lib/studio-project-actions.js
+105
-0
src/views/studio/studio-curator-invite.jsx
src/views/studio/studio-curator-invite.jsx
+45
-0
src/views/studio/studio-curator-inviter.jsx
src/views/studio/studio-curator-inviter.jsx
+53
-0
src/views/studio/studio-curators.jsx
src/views/studio/studio-curators.jsx
+56
-57
src/views/studio/studio-managers.jsx
src/views/studio/studio-managers.jsx
+67
-0
src/views/studio/studio-member-tile.jsx
src/views/studio/studio-member-tile.jsx
+110
-0
src/views/studio/studio-project-adder.jsx
src/views/studio/studio-project-adder.jsx
+53
-0
src/views/studio/studio-project-tile.jsx
src/views/studio/studio-project-tile.jsx
+84
-0
src/views/studio/studio-projects.jsx
src/views/studio/studio-projects.jsx
+45
-42
src/views/studio/studio.jsx
src/views/studio/studio.jsx
+2
-0
src/views/studio/studio.scss
src/views/studio/studio.scss
+169
-1
No files found.
src/redux/studio-permissions.js
View file @
29f6006b
...
...
@@ -29,6 +29,15 @@ const selectCanFollowStudio = state => selectIsLoggedIn(state);
const
selectCanEditCommentsAllowed
=
state
=>
selectIsAdmin
(
state
)
||
isCreator
(
state
);
const
selectCanEditOpenToAll
=
state
=>
isManager
(
state
);
const
selectShowCuratorInvite
=
state
=>
state
.
studio
.
invited
;
const
selectCanInviteCurators
=
state
=>
isManager
(
state
);
const
selectCanRemoveCurators
=
state
=>
isManager
(
state
);
const
selectCanRemoveManager
=
(
state
,
managerId
)
=>
isManager
(
state
)
&&
managerId
!==
state
.
studio
.
owner
;
const
selectCanPromoteCurators
=
state
=>
isManager
(
state
);
// TODO this permission needs to account for who added the project
const
selectCanRemoveProjects
=
state
=>
isCurator
(
state
)
||
isManager
(
state
);
export
{
selectCanEditInfo
,
selectCanAddProjects
,
...
...
@@ -39,5 +48,11 @@ export {
selectCanReportComment
,
selectCanRestoreComment
,
selectCanEditCommentsAllowed
,
selectCanEditOpenToAll
selectCanEditOpenToAll
,
selectShowCuratorInvite
,
selectCanInviteCurators
,
selectCanRemoveCurators
,
selectCanRemoveManager
,
selectCanPromoteCurators
,
selectCanRemoveProjects
};
src/redux/studio.js
View file @
29f6006b
...
...
@@ -148,6 +148,7 @@ module.exports = {
getInfo
,
getRoles
,
setInfo
,
setRoles
,
// Selectors
selectStudioId
,
...
...
src/views/studio/lib/fetchers.js
View file @
29f6006b
const
ITEM_LIMIT
=
4
;
const
projectFetcher
=
(
studioId
,
offset
)
=>
fetch
(
`
${
process
.
env
.
API_HOST
}
/studios/
${
studioId
}
/projects?limit=
${
ITEM_LIMIT
}
&offset=
${
offset
}
`
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
({
items
:
data
,
moreToLoad
:
data
.
length
===
ITEM_LIMIT
}));
const
curatorFetcher
=
(
studioId
,
offset
)
=>
fetch
(
`
${
process
.
env
.
API_HOST
}
/studios/
${
studioId
}
/curators?limit=
${
ITEM_LIMIT
}
&offset=
${
offset
}
`
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
({
items
:
data
,
moreToLoad
:
data
.
length
===
ITEM_LIMIT
}));
const
managerFetcher
=
(
studioId
,
offset
)
=>
fetch
(
`
${
process
.
env
.
API_HOST
}
/studios/
${
studioId
}
/managers?limit=
${
ITEM_LIMIT
}
&offset=
${
offset
}
`
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
({
items
:
data
,
moreToLoad
:
data
.
length
===
ITEM_LIMIT
}));
// TODO move this to studio-activity-actions, include pagination
const
activityFetcher
=
studioId
=>
fetch
(
`
${
process
.
env
.
API_HOST
}
/studios/
${
studioId
}
/activity`
)
.
then
(
response
=>
response
.
json
())
.
then
(
data
=>
({
items
:
data
,
moreToLoad
:
false
}));
// No pagination on the activity feed
export
{
activityFetcher
,
projectFetcher
,
curatorFetcher
,
managerFetcher
activityFetcher
};
src/views/studio/lib/studio-member-actions.js
0 → 100644
View file @
29f6006b
import
keyMirror
from
'
keymirror
'
;
import
api
from
'
../../../lib/api
'
;
import
{
curators
,
managers
}
from
'
./redux-modules
'
;
import
{
selectUsername
}
from
'
../../../redux/session
'
;
import
{
selectStudioId
,
setRoles
}
from
'
../../../redux/studio
'
;
const
Errors
=
keyMirror
({
NETWORK
:
null
,
SERVER
:
null
,
PERMISSION
:
null
});
const
normalizeError
=
(
err
,
body
,
res
)
=>
{
if
(
err
)
return
Errors
.
NETWORK
;
if
(
res
.
statusCode
===
401
||
res
.
statusCode
===
403
)
return
Errors
.
PERMISSION
;
if
(
res
.
statusCode
!==
200
)
return
Errors
.
SERVER
;
return
null
;
};
const
loadManagers
=
()
=>
((
dispatch
,
getState
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
const
managerCount
=
managers
.
selector
(
state
).
items
.
length
;
const
managersPerPage
=
20
;
api
({
uri
:
`/studios/
${
studioId
}
/managers/`
,
params
:
{
limit
:
managersPerPage
,
offset
:
managerCount
}
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
dispatch
(
managers
.
actions
.
error
(
error
));
dispatch
(
managers
.
actions
.
append
(
body
,
body
.
length
===
managersPerPage
));
});
});
const
loadCurators
=
()
=>
((
dispatch
,
getState
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
const
curatorCount
=
curators
.
selector
(
state
).
items
.
length
;
const
curatorsPerPage
=
20
;
api
({
uri
:
`/studios/
${
studioId
}
/curators/`
,
params
:
{
limit
:
curatorsPerPage
,
offset
:
curatorCount
}
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
dispatch
(
curators
.
actions
.
error
(
error
));
dispatch
(
curators
.
actions
.
append
(
body
,
body
.
length
===
curatorsPerPage
));
});
});
const
removeManager
=
username
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
api
({
uri
:
`/site-api/users/curators-in/
${
studioId
}
/remove/`
,
method
:
'
PUT
'
,
withCredentials
:
true
,
useCsrf
:
true
,
params
:
{
usernames
:
username
},
// sic, ?usernames=<username>
host
:
''
// Not handled by the API, use existing infrastructure
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
// Note `body` is undefined, this endpoint returns an html fragment
const
index
=
managers
.
selector
(
getState
()).
items
.
findIndex
(
v
=>
v
.
username
===
username
);
if
(
index
!==
-
1
)
dispatch
(
managers
.
actions
.
remove
(
index
));
// If you are removing yourself, update roles so you stop seeing the manager UI
if
(
selectUsername
(
state
)
===
username
)
{
dispatch
(
setRoles
({
manager
:
false
}));
}
return
resolve
();
});
}));
const
removeCurator
=
username
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
api
({
uri
:
`/site-api/users/curators-in/
${
studioId
}
/remove/`
,
method
:
'
PUT
'
,
withCredentials
:
true
,
useCsrf
:
true
,
params
:
{
usernames
:
username
},
// sic, ?usernames=<username>
host
:
''
// Not handled by the API, use existing infrastructure
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
// Note `body` is undefined, this endpoint returns an html fragment
const
index
=
curators
.
selector
(
getState
()).
items
.
findIndex
(
v
=>
v
.
username
===
username
);
if
(
index
!==
-
1
)
dispatch
(
curators
.
actions
.
remove
(
index
));
return
resolve
();
});
}));
const
inviteCurator
=
username
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
api
({
uri
:
`/site-api/users/curators-in/
${
studioId
}
/invite_curator/`
,
method
:
'
PUT
'
,
withCredentials
:
true
,
useCsrf
:
true
,
params
:
{
usernames
:
username
},
// sic, ?usernames=<username>
host
:
''
// Not handled by the API, use existing infrastructure
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
// eslint-disable-next-line no-alert
alert
(
`successfully invited
${
username
}
`
);
return
resolve
(
username
);
});
}));
const
promoteCurator
=
username
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
api
({
uri
:
`/site-api/users/curators-in/
${
studioId
}
/promote/`
,
method
:
'
PUT
'
,
withCredentials
:
true
,
useCsrf
:
true
,
params
:
{
usernames
:
username
},
// sic, ?usernames=<username>
host
:
''
// Not handled by the API, use existing infrastructure
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
const
curatorList
=
curators
.
selector
(
getState
()).
items
;
const
index
=
curatorList
.
findIndex
(
v
=>
v
.
username
===
username
);
const
curatorItem
=
curatorList
[
index
];
if
(
index
!==
-
1
)
dispatch
(
curators
.
actions
.
remove
(
index
));
dispatch
(
managers
.
actions
.
create
(
curatorItem
));
return
resolve
();
});
}));
const
acceptInvitation
=
()
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
username
=
selectUsername
(
state
);
const
studioId
=
selectStudioId
(
state
);
api
({
uri
:
`/site-api/users/curators-in/
${
studioId
}
/add/`
,
method
:
'
PUT
'
,
withCredentials
:
true
,
useCsrf
:
true
,
params
:
{
usernames
:
username
},
// sic, ?usernames=<username>
host
:
''
// Not handled by the API, use existing infrastructure
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
api
({
uri
:
`/users/
${
username
}
`
},
(
userErr
,
userBody
,
userRes
)
=>
{
const
userError
=
normalizeError
(
userErr
,
userBody
,
userRes
);
if
(
userError
)
return
reject
(
userError
);
// Note: this assumes that the user items from the curator endpoint
// are the same structure as the single user data returned from /users/:username
dispatch
(
curators
.
actions
.
create
(
userBody
));
dispatch
(
setRoles
({
invited
:
false
,
curator
:
true
}));
return
resolve
();
});
});
}));
export
{
Errors
,
loadManagers
,
loadCurators
,
inviteCurator
,
acceptInvitation
,
promoteCurator
,
removeCurator
,
removeManager
};
src/views/studio/lib/studio-project-actions.js
0 → 100644
View file @
29f6006b
import
keyMirror
from
'
keymirror
'
;
import
api
from
'
../../../lib/api
'
;
import
{
selectToken
}
from
'
../../../redux/session
'
;
import
{
selectStudioId
}
from
'
../../../redux/studio
'
;
import
{
projects
}
from
'
./redux-modules
'
;
const
Errors
=
keyMirror
({
NETWORK
:
null
,
SERVER
:
null
,
PERMISSION
:
null
});
const
normalizeError
=
(
err
,
body
,
res
)
=>
{
if
(
err
)
return
Errors
.
NETWORK
;
if
(
res
.
statusCode
===
401
||
res
.
statusCode
===
403
)
return
Errors
.
PERMISSION
;
if
(
res
.
statusCode
!==
200
)
return
Errors
.
SERVER
;
return
null
;
};
const
loadProjects
=
()
=>
((
dispatch
,
getState
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
const
projectCount
=
projects
.
selector
(
state
).
items
.
length
;
const
projectsPerPage
=
20
;
api
({
uri
:
`/studios/
${
studioId
}
/projects/`
,
params
:
{
limit
:
projectsPerPage
,
offset
:
projectCount
}
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
dispatch
(
projects
.
actions
.
error
(
error
));
dispatch
(
projects
.
actions
.
append
(
body
,
body
.
length
===
projectsPerPage
));
});
});
/**
* Generate a project list item matching the shape of the initial
* project list request. The POST request that adds projects would
* ideally respond with this format directly. For now, merge data
* from the POST and a follow-up GET request for additional project data.
*
* @param {object} postBody - body of response to POST that adds the project
* @param {object} infoBody - body of the follow-up GET for more project data.
* @returns {object} project list item
*/
const
generateProjectListItem
=
(
postBody
,
infoBody
)
=>
({
// Fields from the POST to add the project to the studio
id
:
postBody
.
projectId
,
actor_id
:
postBody
.
actorId
,
// Fields from followup GET for more project info
title
:
infoBody
.
title
,
image
:
infoBody
.
image
,
creator_id
:
infoBody
.
author
.
id
,
username
:
infoBody
.
author
.
username
,
avatar
:
infoBody
.
author
.
profile
.
images
});
const
addProject
=
projectId
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
const
token
=
selectToken
(
state
);
api
({
uri
:
`/studios/
${
studioId
}
/project/
${
projectId
}
`
,
method
:
'
POST
'
,
authentication
:
token
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
// Would prefer if the POST returned the exact data / format we want...
api
({
uri
:
`/projects/
${
projectId
}
`
},
(
infoErr
,
infoBody
,
infoRes
)
=>
{
const
infoError
=
normalizeError
(
infoErr
,
infoBody
,
infoRes
);
if
(
infoError
)
return
reject
(
infoError
);
const
newItem
=
generateProjectListItem
(
body
,
infoBody
);
dispatch
(
projects
.
actions
.
create
(
newItem
));
return
resolve
(
newItem
);
});
});
}));
const
removeProject
=
projectId
=>
((
dispatch
,
getState
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
const
state
=
getState
();
const
studioId
=
selectStudioId
(
state
);
const
token
=
selectToken
(
state
);
api
({
uri
:
`/studios/
${
studioId
}
/project/
${
projectId
}
`
,
method
:
'
DELETE
'
,
authentication
:
token
},
(
err
,
body
,
res
)
=>
{
const
error
=
normalizeError
(
err
,
body
,
res
);
if
(
error
)
return
reject
(
error
);
const
index
=
projects
.
selector
(
getState
()).
items
.
findIndex
(
v
=>
v
.
id
===
projectId
);
if
(
index
!==
-
1
)
dispatch
(
projects
.
actions
.
remove
(
index
));
return
resolve
();
});
}));
export
{
Errors
,
loadProjects
,
addProject
,
removeProject
};
src/views/studio/studio-curator-invite.jsx
0 → 100644
View file @
29f6006b
/* eslint-disable react/jsx-no-bind */
import
React
,
{
useState
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
classNames
from
'
classnames
'
;
import
{
acceptInvitation
}
from
'
./lib/studio-member-actions
'
;
const
StudioCuratorInvite
=
({
onSubmit
})
=>
{
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
null
);
return
(
<
div
>
<
button
className=
{
classNames
(
'
button
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onSubmit
()
.
catch
(
e
=>
{
setError
(
e
);
setSubmitting
(
false
);
});
}
}
>
Accept invite
</
button
>
{
error
&&
<
div
>
{
error
}
</
div
>
}
</
div
>
);
};
StudioCuratorInvite
.
propTypes
=
{
onSubmit
:
PropTypes
.
func
};
const
mapStateToProps
=
()
=>
({});
const
mapDispatchToProps
=
({
onSubmit
:
acceptInvitation
});
export
default
connect
(
mapStateToProps
,
mapDispatchToProps
)(
StudioCuratorInvite
);
src/views/studio/studio-curator-inviter.jsx
0 → 100644
View file @
29f6006b
/* eslint-disable react/jsx-no-bind */
import
React
,
{
useState
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
classNames
from
'
classnames
'
;
import
{
inviteCurator
}
from
'
./lib/studio-member-actions
'
;
const
StudioCuratorInviter
=
({
onSubmit
})
=>
{
const
[
value
,
setValue
]
=
useState
(
''
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
null
);
return
(
<
div
className=
"studio-adder-section"
>
<
h3
>
✦ Invite Curators
</
h3
>
<
input
disabled=
{
submitting
}
type=
"text"
placeholder=
"<username>"
value=
{
value
}
onChange=
{
e
=>
setValue
(
e
.
target
.
value
)
}
/>
<
button
className=
{
classNames
(
'
button
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onSubmit
(
value
)
.
then
(()
=>
setValue
(
''
))
.
catch
(
e
=>
setError
(
e
))
.
then
(()
=>
setSubmitting
(
false
));
}
}
>
Invite
</
button
>
{
error
&&
<
div
>
{
error
}
</
div
>
}
</
div
>
);
};
StudioCuratorInviter
.
propTypes
=
{
onSubmit
:
PropTypes
.
func
};
const
mapStateToProps
=
()
=>
({});
const
mapDispatchToProps
=
({
onSubmit
:
inviteCurator
});
export
default
connect
(
mapStateToProps
,
mapDispatchToProps
)(
StudioCuratorInviter
);
src/views/studio/studio-curators.jsx
View file @
29f6006b
import
React
,
{
useEffect
,
useCallback
}
from
'
react
'
;
import
React
,
{
useEffect
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
useParams
}
from
'
react-router-dom
'
;
import
{
connect
}
from
'
react-redux
'
;
import
{
curators
,
managers
}
from
'
./lib/redux-modules
'
;
import
{
curatorFetcher
,
managerFetcher
}
from
'
./lib/fetchers
'
;
import
{
curators
}
from
'
./lib/redux-modules
'
;
import
Debug
from
'
./debug.jsx
'
;
import
{
CuratorTile
}
from
'
./studio-member-tile.jsx
'
;
import
CuratorInviter
from
'
./studio-curator-inviter.jsx
'
;
import
CuratorInvite
from
'
./studio-curator-invite.jsx
'
;
import
{
loadCurators
}
from
'
./lib/studio-member-actions
'
;
import
{
selectCanInviteCurators
,
selectShowCuratorInvite
}
from
'
../../redux/studio-permissions
'
;
const
StudioCurators
=
()
=>
{
const
{
studioId
}
=
useParams
();
return
(
<
div
>
<
h3
>
Managers
</
h3
>
<
ManagerList
studioId=
{
studioId
}
/>
<
hr
/>
<
h3
>
Curators
</
h3
>
<
CuratorList
studioId=
{
studioId
}
/>
</
div
>
);
};
const
MemberList
=
({
studioId
,
items
,
error
,
loading
,
moreToLoad
,
onLoadMore
})
=>
{
const
StudioCurators
=
({
canInviteCurators
,
showCuratorInvite
,
items
,
error
,
loading
,
moreToLoad
,
onLoadMore
})
=>
{
useEffect
(()
=>
{
if
(
studioId
&&
items
.
length
===
0
)
onLoadMore
(
studioId
,
0
);
},
[
studioId
]);
const
handleLoadMore
=
useCallback
(()
=>
onLoadMore
(
studioId
,
items
.
length
),
[
studioId
,
items
.
length
]);
if
(
items
.
length
===
0
)
onLoadMore
();
},
[]);
return
(<
React
.
Fragment
>
return
(<
div
className=
"studio-members"
>
<
h2
>
Curators
</
h2
>
{
canInviteCurators
&&
<
CuratorInviter
/>
}
{
showCuratorInvite
&&
<
CuratorInvite
/>
}
{
error
&&
<
Debug
label=
"Error"
data=
{
error
}
/>
}
{
items
.
map
((
item
,
index
)
=>
(<
Debug
label=
"Member"
data=
{
item
}
key=
{
index
}
/>)
)
}
{
loading
?
<
small
>
Loading...
</
small
>
:
(
moreToLoad
?
<
button
onClick=
{
handleLoadMore
}
>
<
div
className=
"studio-members-grid"
>
{
items
.
map
(
item
=>
(<
CuratorTile
key=
{
item
.
username
}
username=
{
item
.
username
}
image=
{
item
.
profile
.
images
[
'
90x90
'
]
}
/>)
)
}
<
div
className=
"studio-members-load-more"
>
{
loading
?
<
small
>
Loading...
</
small
>
:
(
moreToLoad
?
<
button
onClick=
{
onLoadMore
}
>
Load more
</
button
>
:
<
small
>
No more to load
</
small
>
)
}
</
React
.
Fragment
>);
</
button
>
:
<
small
>
No more to load
</
small
>
)
}
</
div
>
</
div
>
</
div
>);
};
MemberList
.
propTypes
=
{
studioId
:
PropTypes
.
string
,
items
:
PropTypes
.
array
,
// eslint-disable-line react/forbid-prop-types
StudioCurators
.
propTypes
=
{
items
:
PropTypes
.
arrayOf
(
PropTypes
.
shape
({
id
:
PropTypes
.
id
,
username
:
PropTypes
.
string
,
profile
:
PropTypes
.
shape
({
images
:
PropTypes
.
shape
({
'
90x90
'
:
PropTypes
.
string
})
})
})),
canInviteCurators
:
PropTypes
.
bool
,
showCuratorInvite
:
PropTypes
.
bool
,
loading
:
PropTypes
.
bool
,
error
:
PropTypes
.
object
,
// eslint-disable-line react/forbid-prop-types
moreToLoad
:
PropTypes
.
bool
,
onLoadMore
:
PropTypes
.
func
};
const
ManagerList
=
connect
(
state
=>
managers
.
selector
(
state
),
dispatch
=>
({
onLoadMore
:
(
studioId
,
offset
)
=>
dispatch
(
managers
.
actions
.
loadMore
(
managerFetcher
.
bind
(
null
,
studioId
,
offset
)))
})
)(
MemberList
);
const
CuratorList
=
connect
(
state
=>
curators
.
selector
(
state
),
dispatch
=>
({
onLoadMore
:
(
studioId
,
offset
)
=>
dispatch
(
curators
.
actions
.
loadMore
(
curatorFetcher
.
bind
(
null
,
studioId
,
offset
)))
})
)(
MemberList
);
export
default
StudioCurators
;
export
default
connect
(
state
=>
({
...
curators
.
selector
(
state
),
canInviteCurators
:
selectCanInviteCurators
(
state
),
showCuratorInvite
:
selectShowCuratorInvite
(
state
)
}),
{
onLoadMore
:
loadCurators
}
)(
StudioCurators
);
src/views/studio/studio-managers.jsx
0 → 100644
View file @
29f6006b
import
React
,
{
useEffect
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
{
managers
}
from
'
./lib/redux-modules
'
;
import
{
loadManagers
}
from
'
./lib/studio-member-actions
'
;
import
Debug
from
'
./debug.jsx
'
;
import
{
ManagerTile
}
from
'
./studio-member-tile.jsx
'
;
const
StudioManagers
=
({
items
,
error
,
loading
,
moreToLoad
,
onLoadMore
})
=>
{
useEffect
(()
=>
{
if
(
items
.
length
===
0
)
onLoadMore
();
},
[]);
return
(
<
div
className=
"studio-members"
>
<
h2
>
Managers
</
h2
>
{
error
&&
<
Debug
label=
"Error"
data=
{
error
}
/>
}
<
div
className=
"studio-members-grid"
>
{
items
.
map
(
item
=>
(<
ManagerTile
key=
{
item
.
username
}
id=
{
item
.
id
}
username=
{
item
.
username
}
image=
{
item
.
profile
.
images
[
'
90x90
'
]
}
/>)
)
}
<
div
className=
"studio-members-load-more"
>
{
loading
?
<
small
>
Loading...
</
small
>
:
(
moreToLoad
?
<
button
onClick=
{
onLoadMore
}
>
Load more
</
button
>
:
<
small
>
No more to load
</
small
>
)
}
</
div
>
</
div
>
</
div
>
);
};
StudioManagers
.
propTypes
=
{
items
:
PropTypes
.
arrayOf
(
PropTypes
.
shape
({
id
:
PropTypes
.
id
,
username
:
PropTypes
.
string
,
profile
:
PropTypes
.
shape
({
images
:
PropTypes
.
shape
({
'
90x90
'
:
PropTypes
.
string
})
})
})),
loading
:
PropTypes
.
bool
,
error
:
PropTypes
.
object
,
// eslint-disable-line react/forbid-prop-types
moreToLoad
:
PropTypes
.
bool
,
onLoadMore
:
PropTypes
.
func
};
export
default
connect
(
state
=>
managers
.
selector
(
state
),
{
onLoadMore
:
loadManagers
}
)(
StudioManagers
);
src/views/studio/studio-member-tile.jsx
0 → 100644
View file @
29f6006b
/* eslint-disable react/jsx-no-bind */
import
React
,
{
useState
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
classNames
from
'
classnames
'
;
import
{
selectCanRemoveCurators
,
selectCanRemoveManager
,
selectCanPromoteCurators
}
from
'
../../redux/studio-permissions
'
;
import
{
promoteCurator
,
removeCurator
,
removeManager
}
from
'
./lib/studio-member-actions
'
;
const
StudioMemberTile
=
({
canRemove
,
canPromote
,
onRemove
,
onPromote
,
isCreator
,
// mapState props
username
,
image
// own props
})
=>
{
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
null
);
const
userUrl
=
`/users/
${
username
}
`
;
return
(
<
div
className=
"studio-member-tile"
>
<
a
href=
{
userUrl
}
>
<
img
className=
"studio-member-image"
src=
{
image
}
/>
</
a
>
<
div
className=
"studio-member-info"
>
<
a
href=
{
userUrl
}
className=
"studio-member-name"
>
{
username
}
</
a
>
{
isCreator
&&
<
div
className=
"studio-member-role"
>
Studio Creator
</
div
>
}
</
div
>
{
canRemove
&&
<
button
className=
{
classNames
(
'
studio-member-remove
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onRemove
(
username
).
catch
(
e
=>
{
setError
(
e
);
setSubmitting
(
false
);
});
}
}
>
✕
</
button
>
}
{
canPromote
&&
<
button
className=
{
classNames
(
'
studio-member-promote
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onPromote
(
username
).
catch
(
e
=>
{
setError
(
e
);
setSubmitting
(
false
);
});
}
}
>
🆙
</
button
>
}
{
error
&&
<
div
>
{
error
}
</
div
>
}
</
div
>
);
};
StudioMemberTile
.
propTypes
=
{
canRemove
:
PropTypes
.
bool
,
canPromote
:
PropTypes
.
bool
,
onRemove
:
PropTypes
.
func
,
onPromote
:
PropTypes
.
func
,
username
:
PropTypes
.
string
,
image
:
PropTypes
.
string
,
isCreator
:
PropTypes
.
bool
};
const
ManagerTile
=
connect
(
(
state
,
ownProps
)
=>
({
canRemove
:
selectCanRemoveManager
(
state
,
ownProps
.
id
),
canPromote
:
false
,
isCreator
:
state
.
studio
.
owner
===
ownProps
.
id
}),
{
onRemove
:
removeManager
}
)(
StudioMemberTile
);
const
CuratorTile
=
connect
(
state
=>
({
canRemove
:
selectCanRemoveCurators
(
state
),
canPromote
:
selectCanPromoteCurators
(
state
)
}),
{
onRemove
:
removeCurator
,
onPromote
:
promoteCurator
}
)(
StudioMemberTile
);
export
{
ManagerTile
,
CuratorTile
};
src/views/studio/studio-project-adder.jsx
0 → 100644
View file @
29f6006b
/* eslint-disable react/jsx-no-bind */
import
React
,
{
useState
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
classNames
from
'
classnames
'
;
import
{
addProject
}
from
'
./lib/studio-project-actions
'
;
const
StudioProjectAdder
=
({
onSubmit
})
=>
{
const
[
value
,
setValue
]
=
useState
(
''
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
null
);
return
(
<
div
className=
"studio-adder-section"
>
<
h3
>
✦ Add Projects
</
h3
>
<
input
disabled=
{
submitting
}
type=
"text"
placeholder=
"<project id>"
value=
{
value
}
onChange=
{
e
=>
setValue
(
e
.
target
.
value
)
}
/>
<
button
className=
{
classNames
(
'
button
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onSubmit
(
value
)
.
then
(()
=>
setValue
(
''
))
.
catch
(
e
=>
setError
(
e
))
.
then
(()
=>
setSubmitting
(
false
));
}
}
>
Add
</
button
>
{
error
&&
<
div
>
{
error
}
</
div
>
}
</
div
>
);
};
StudioProjectAdder
.
propTypes
=
{
onSubmit
:
PropTypes
.
func
};
const
mapStateToProps
=
()
=>
({});
const
mapDispatchToProps
=
({
onSubmit
:
addProject
});
export
default
connect
(
mapStateToProps
,
mapDispatchToProps
)(
StudioProjectAdder
);
src/views/studio/studio-project-tile.jsx
0 → 100644
View file @
29f6006b
/* eslint-disable react/jsx-no-bind */
import
React
,
{
useState
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
connect
}
from
'
react-redux
'
;
import
classNames
from
'
classnames
'
;
import
{
selectCanRemoveProjects
}
from
'
../../redux/studio-permissions
'
;
import
{
removeProject
}
from
'
./lib/studio-project-actions
'
;
const
StudioProjectTile
=
({
canRemove
,
onRemove
,
// mapState props
id
,
title
,
image
,
avatar
,
username
// own props
})
=>
{
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
null
);
const
projectUrl
=
`/projects/
${
id
}
`
;
const
userUrl
=
`/users/
${
username
}
`
;
return
(
<
div
className=
"studio-project-tile"
>
<
a
href=
{
projectUrl
}
>
<
img
className=
"studio-project-image"
src=
{
image
}
/>
</
a
>
<
div
className=
"studio-project-bottom"
>
<
a
href=
{
userUrl
}
>
<
img
className=
"studio-project-avatar"
src=
{
avatar
}
/>
</
a
>
<
div
className=
"studio-project-info"
>
<
a
href=
{
projectUrl
}
className=
"studio-project-title"
>
{
title
}
</
a
>
<
a
href=
{
userUrl
}
className=
"studio-project-username"
>
{
username
}
</
a
>
</
div
>
{
canRemove
&&
<
button
className=
{
classNames
(
'
studio-project-remove
'
,
{
'
mod-mutating
'
:
submitting
})
}
disabled=
{
submitting
}
onClick=
{
()
=>
{
setSubmitting
(
true
);
setError
(
null
);
onRemove
(
id
)
.
catch
(
e
=>
{
setError
(
e
);
setSubmitting
(
false
);
});
}
}
>
✕
</
button
>
}
{
error
&&
<
div
>
{
error
}
</
div
>
}
</
div
>
</
div
>
);
};
StudioProjectTile
.
propTypes
=
{
canRemove
:
PropTypes
.
bool
,
onRemove
:
PropTypes
.
func
,
id
:
PropTypes
.
number
,
title
:
PropTypes
.
string
,
username
:
PropTypes
.
string
,
image
:
PropTypes
.
string
,
avatar
:
PropTypes
.
string
};
const
mapStateToProps
=
state
=>
({
canRemove
:
selectCanRemoveProjects
(
state
)
});
const
mapDispatchToProps
=
({
onRemove
:
removeProject
});
export
default
connect
(
mapStateToProps
,
mapDispatchToProps
)(
StudioProjectTile
);
src/views/studio/studio-projects.jsx
View file @
29f6006b
import
React
,
{
useEffect
,
useCallback
}
from
'
react
'
;
import
React
,
{
useEffect
}
from
'
react
'
;
import
PropTypes
from
'
prop-types
'
;
import
{
useParams
}
from
'
react-router-dom
'
;
import
{
connect
}
from
'
react-redux
'
;
import
StudioOpenToAll
from
'
./studio-open-to-all.jsx
'
;
import
{
projectFetcher
}
from
'
./lib/fetchers
'
;
import
{
projects
}
from
'
./lib/redux-modules
'
;
import
{
selectCanAddProjects
,
selectCanEditOpenToAll
}
from
'
../../redux/studio-permissions
'
;
import
Debug
from
'
./debug.jsx
'
;
const
{
actions
,
selector
:
projectsSelector
}
=
projects
;
import
StudioProjectAdder
from
'
./studio-project-adder.jsx
'
;
import
StudioProjectTile
from
'
./studio-project-tile.jsx
'
;
import
{
loadProjects
}
from
'
./lib/studio-project-actions.js
'
;
const
StudioProjects
=
({
canAddProjects
,
canEditOpenToAll
,
items
,
error
,
loading
,
moreToLoad
,
onLoadMore
})
=>
{
const
{
studioId
}
=
useParams
();
useEffect
(()
=>
{
if
(
studioId
&&
items
.
length
===
0
)
onLoadMore
(
studioId
,
0
);
},
[
studioId
]);
const
handleLoadMore
=
useCallback
(()
=>
onLoadMore
(
studioId
,
items
.
length
),
[
studioId
,
items
.
length
]);
if
(
items
.
length
===
0
)
onLoadMore
();
},
[]);
return
(
<
div
>
<
div
className=
"studio-projects"
>
<
h2
>
Projects
</
h2
>
{
canEditOpenToAll
&&
<
StudioOpenToAll
/>
}
{
canAddProjects
&&
<
StudioProjectAdder
/>
}
{
error
&&
<
Debug
label=
"Error"
data=
{
error
}
/>
}
<
Debug
label=
"Project Permissions"
data=
{
{
canAddProjects
}
}
/>
<
div
>
{
items
.
map
((
item
,
index
)
=>
(<
Debug
label=
"Project"
data=
{
item
}
key=
{
index
}
<
div
className=
"studio-projects-grid"
>
{
items
.
map
(
item
=>
(<
StudioProjectTile
fetching=
{
loading
}
key=
{
item
.
id
}
id=
{
item
.
id
}
title=
{
item
.
title
}
image=
{
item
.
image
}
avatar=
{
item
.
avatar
[
'
90x90
'
]
}
username=
{
item
.
username
}
/>)
)
}
{
loading
?
<
small
>
Loading...
</
small
>
:
(
moreToLoad
?
<
button
onClick=
{
handleLoadMore
}
>
<
div
className=
"studio-projects-load-more"
>
{
loading
?
<
small
>
Loading...
</
small
>
:
(
moreToLoad
?
<
button
onClick=
{
onLoadMore
}
>
Load more
</
button
>
:
<
small
>
No more to load
</
small
>
)
}
</
button
>
:
<
small
>
No more to load
</
small
>
)
}
</
div
>
</
div
>
</
div
>
);
...
...
@@ -57,22 +55,27 @@ const StudioProjects = ({
StudioProjects
.
propTypes
=
{
canAddProjects
:
PropTypes
.
bool
,
canEditOpenToAll
:
PropTypes
.
bool
,
items
:
PropTypes
.
array
,
// eslint-disable-line react/forbid-prop-types
items
:
PropTypes
.
arrayOf
(
PropTypes
.
shape
({
avatar
:
PropTypes
.
shape
({
'
90x90
'
:
PropTypes
.
string
}),
id
:
PropTypes
.
id
,
title
:
PropTypes
.
string
,
username
:
PropTypes
.
string
})),
loading
:
PropTypes
.
bool
,
error
:
PropTypes
.
object
,
// eslint-disable-line react/forbid-prop-types
moreToLoad
:
PropTypes
.
bool
,
onLoadMore
:
PropTypes
.
func
};
const
mapStateToProps
=
state
=>
({
...
projectsSelector
(
state
),
canAddProjects
:
selectCanAddProjects
(
state
),
canEditOpenToAll
:
selectCanEditOpenToAll
(
state
)
});
const
mapDispatchToProps
=
dispatch
=>
({
onLoadMore
:
(
studioId
,
offset
)
=>
dispatch
(
actions
.
loadMore
(
projectFetcher
.
bind
(
null
,
studioId
,
offset
))
)
});
export
default
connect
(
mapStateToProps
,
mapDispatchToProps
)(
StudioProjects
);
export
default
connect
(
state
=>
({
...
projects
.
selector
(
state
),
canAddProjects
:
selectCanAddProjects
(
state
),
canEditOpenToAll
:
selectCanEditOpenToAll
(
state
)
}),
{
onLoadMore
:
loadProjects
}
)(
StudioProjects
);
src/views/studio/studio.jsx
View file @
29f6006b
...
...
@@ -13,6 +13,7 @@ import render from '../../lib/render.jsx';
import
StudioTabNav
from
'
./studio-tab-nav.jsx
'
;
import
StudioProjects
from
'
./studio-projects.jsx
'
;
import
StudioInfo
from
'
./studio-info.jsx
'
;
import
StudioManagers
from
'
./studio-managers.jsx
'
;
import
StudioCurators
from
'
./studio-curators.jsx
'
;
import
StudioComments
from
'
./studio-comments.jsx
'
;
import
StudioActivity
from
'
./studio-activity.jsx
'
;
...
...
@@ -43,6 +44,7 @@ const StudioShell = () => {
<
div
>
<
Switch
>
<
Route
path=
{
`${match.path}/curators`
}
>
<
StudioManagers
/>
<
StudioCurators
/>
</
Route
>
<
Route
path=
{
`${match.path}/comments`
}
>
...
...
src/views/studio/studio.scss
View file @
29f6006b
...
...
@@ -67,6 +67,174 @@ $radius: 8px;
.active
>
li
{
background
:
$ui-blue
;
}
}
.studio-projects
{}
.studio-projects-grid
{
margin-top
:
20px
;
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1fr
);
@media
#{
$medium
}
{
&
{
grid-template-columns
:
repeat
(
2
,
minmax
(
0
,
1fr
));
}
}
@media
#{
$big
}
{
&
{
grid-template-columns
:
repeat
(
3
,
minmax
(
0
,
1fr
));
}
}
column-gap
:
30px
;
row-gap
:
20px
;
.studio-projects-load-more
{
grid-column
:
1
/
-1
;
}
}
.studio-project-tile
{
background
:
white
;
border-radius
:
8px
;
border
:
1px
solid
$ui-border
;
.studio-project-image
{
max-width
:
100%
;
background
:
#a0c6fc
;
border-top-left-radius
:
8px
;
border-top-right-radius
:
8px
;
}
.studio-project-bottom
{
display
:
flex
;
padding
:
10px
6px
10px
12px
;
justify-content
:
space-between
;
}
.studio-project-avatar
{
width
:
42px
;
height
:
42px
;
border-radius
:
4px
;
object-fit
:
cover
;
}
.studio-project-info
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
space-around
;
overflow
:
hidden
;
margin
:
0
8px
;
flex-grow
:
1
;
/* Grow to fill available space */
min-width
:
0
;
/* Prevents within from expanding beyond bounds */
}
.studio-project-title
{
color
:
#4C97FF
;
font-weight
:
700
;
font-size
:
14px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
;
}
.studio-project-username
{
color
:
#575E75
;
font-weight
:
700
;
font-size
:
12px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
;
}
.studio-project-remove
{
color
:
$ui-blue
;
background
:
transparent
;
border
:
none
;
}
}
.studio-members
{}
.studio-members-grid
{
margin-top
:
20px
;
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1fr
);
@media
#{
$medium
}
{
&
{
grid-template-columns
:
repeat
(
2
,
minmax
(
0
,
1fr
));
}
}
@media
#{
$big
}
{
&
{
grid-template-columns
:
repeat
(
3
,
minmax
(
0
,
1fr
));
}
}
column-gap
:
30px
;
row-gap
:
20px
;
.studio-members-load-more
{
grid-column
:
1
/
-1
;
}
}
.studio-member-tile
{
background
:
white
;
border-radius
:
8px
;
border
:
1px
solid
$ui-border
;
display
:
flex
;
padding
:
10px
6px
10px
12px
;
justify-content
:
space-between
;
.studio-member-image
{
width
:
42px
;
height
:
42px
;
border-radius
:
4px
;
object-fit
:
cover
;
}
.studio-member-info
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
space-around
;
overflow
:
hidden
;
margin
:
0
8px
;
flex-grow
:
1
;
/* Grow to fill available space */
min-width
:
0
;
/* Prevents within from expanding beyond bounds */
}
.studio-member-name
{
color
:
#4C97FF
;
font-weight
:
700
;
font-size
:
14px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
;
}
.studio-member-role
{
color
:
#575E75
;
font-weight
:
400
;
font-size
:
12px
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
;
}
.studio-member-remove
,
.studio-member-promote
{
color
:
$ui-blue
;
background
:
transparent
;
border
:
none
;
}
}
.studio-members
+
.studio-members
{
margin-top
:
40px
;
}
.studio-adder-section
{
margin-top
:
20px
;
display
:
flex
;
flex-wrap
:
wrap
;
h3
{
color
:
#4C97FF
;
}
input
{
flex-basis
:
80%
;
flex-grow
:
1
;
display
:
inline-block
;
margin
:
.5em
0
;
border
:
1px
solid
$ui-border
;
border-radius
:
.5rem
;
padding
:
1em
1
.25em
;
font-size
:
.8rem
;
}
button
{
flex-grow
:
1
;
}
input
+
button
{
margin-inline-start
:
12px
;
}
}
/* Modification classes for different interaction states */
.mod-fetching
{
/* When a field has no content to display yet */
...
...
@@ -93,6 +261,6 @@ $radius: 8px;
}
.mod-mutating
{
/* When a field has sent a change to the server */
cursor
:
wait
;
cursor
:
wait
!
important
;
opacity
:
.5
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment