Dwayne Harris 5 years ago
parent
commit
5e719fa673
  1. 29
      src/actions/directory.ts
  2. 2
      src/api/fetch.ts
  3. 6
      src/components/app/app.scss
  4. 4
      src/components/app/app.tsx
  5. 17
      src/components/member-list/index.tsx
  6. 41
      src/components/member-list/member-list-item/index.tsx
  7. 9
      src/components/pages/directory/directory.tsx
  8. 130
      src/components/pages/group-admin/group-admin.tsx
  9. 29
      src/components/pages/group-admin/index.ts
  10. 31
      src/components/pages/group/group.tsx
  11. 7
      src/components/pages/login/login.tsx
  12. 15
      src/components/pages/self/self.tsx
  13. 4
      src/components/user-info/user-info.tsx
  14. 14
      src/selectors/directory.ts
  15. 7
      src/types/entities.ts
  16. 1
      src/types/store.ts

29
src/actions/directory.ts

@ -4,10 +4,10 @@ import { normalize } from 'normalizr'
import { apiFetch } from 'src/api'
import { setEntity, setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
import { groupSchema } from 'src/store/schemas'
import { groupSchema, userSchema } from 'src/store/schemas'
import { objectToQuerystring } from 'src/utils'
import { AppThunkAction, Entity, RequestKey, EntityType } from 'src/types'
import { AppThunkAction, Entity, RequestKey, EntityType, User } from 'src/types'
export interface SetGroupsAction extends Action {
type: 'DIRECTORY_SET_GROUPS'
@ -69,7 +69,7 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio
try {
const response = await apiFetch<GroupsResponse>({
path: `/api/groups?${objectToQuerystring({ sort, continuation })}`
path: `/api/groups?${objectToQuerystring({ sort, continuation })}`,
})
const groups = normalize(response.groups, [groupSchema])
@ -87,3 +87,26 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio
throw err
}
}
interface GroupMembersResponse {
members: User[]
continuation?: string
}
export const fetchGroupMembers = (id: string, type?: string, continuation?: string): AppThunkAction => async dispatch => {
dispatch(startRequest(RequestKey.FetchGroupMembers))
try {
const response = await apiFetch<GroupMembersResponse>({
path: `/api/group/${id}/members?${objectToQuerystring({ type, continuation })}`,
})
const users = normalize(response.members, [userSchema])
dispatch(setEntities(users.entities))
dispatch(finishRequest(RequestKey.FetchGroupMembers, true))
} catch (err) {
dispatch(finishRequest(RequestKey.FetchGroupMembers, false))
throw err
}
}

2
src/api/fetch.ts

@ -95,7 +95,7 @@ export const apiFetch: APIFetch = async (options: FetchOptions) => {
const refreshToken = localStorage.getItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY)
if (accessToken && refreshToken) {
const refreshResponse = await fetch('/api/refresh', {
const refreshResponse = await fetch(`${config.apiUrl}/api/refresh`, {
headers: new Headers({
'Content-Type': contentType,
'Authorization': `Bearer ${accessToken}`

6
src/components/app/app.scss

@ -27,14 +27,16 @@ $body-size: 14px;
@import "../../../node_modules/bulma/sass/elements/icon.sass";
@import "../../../node_modules/bulma/sass/elements/notification.sass";
@import "../../../node_modules/bulma/sass/elements/other.sass";
@import "../../../node_modules/bulma/sass/elements/tag.sass";
@import "../../../node_modules/bulma/sass/elements/title.sass";
@import "../../../node_modules/bulma/sass/layout/hero.sass";
@import "../../../node_modules/bulma/sass/components/level.sass";
@import "../../../node_modules/bulma/sass/components/media.sass";
@import "../../../node_modules/bulma/sass/components/tabs.sass";
div#main-menu {
background-color: $primary;
border-left: 1px solid $grey-lighter;
background: linear-gradient(135deg, $primary, darken($primary, 20%));
// border-left: 1px solid $grey-lighter;
bottom: 0;
display: flex;
flex-direction: column;

4
src/components/app/app.tsx

@ -13,6 +13,7 @@ import About from '../pages/about'
import Developers from '../pages/developers'
import Directory from '../pages/directory'
import Group from '../pages/group'
import GroupAdmin from '../pages/group-admin'
import Home from '../pages/home'
import Login from '../pages/login'
import Register from '../pages/register'
@ -60,7 +61,8 @@ const App: FC<Props> = ({ collapsed, fetching, fetchSelf, setChecked }) => {
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/c/:id" component={Group} />
<Route path="/c/:id" component={Group} exact />
<Route path="/c/:id/admin/:tab?" component={GroupAdmin} />
<Route path="/c/:id/register" component={RegisterGroup} />
<Route path="/communities" component={Directory} />
<Route path="/self" component={Self} />

17
src/components/member-list/index.tsx

@ -0,0 +1,17 @@
import React, { FC } from 'react'
import { User } from 'src/types'
import MemberListItem from './member-list-item'
interface Props {
members: User[]
}
const MemberList: FC<Props> = ({ members }) => (
<div className="is-flex">
{members.map(member => <MemberListItem key={member.id} member={member} />)}
</div>
)
export default MemberList

41
src/components/member-list/member-list-item/index.tsx

@ -0,0 +1,41 @@
import React, { FC } from 'react'
import { Link } from 'react-router-dom'
import classNames from 'classnames'
import capitalize from 'lodash/capitalize'
import { User, GroupMembershipType, ClassDictionary } from 'src/types'
interface Props {
member: User
}
const MemberListItem: FC<Props> = ({ member }) => {
const tagClass = () => {
switch (member.membership as GroupMembershipType) {
case GroupMembershipType.Admin: return 'is-success'
case GroupMembershipType.Moderator: return 'is-warning'
case GroupMembershipType.Member: return 'is-info'
}
}
const tagClassDictionary: ClassDictionary = {
tag: true,
[tagClass()]: true,
}
return (
<article className="media">
<div className="media-content">
<div className="content">
<Link to={`/u/${member.id}`} className="is-size-5">{member.name}</Link>
<br />
<Link to={`/u/${member.id}`} className="is-size-6 has-text-red">@{member.id}</Link>
<br />
<span className={classNames(tagClassDictionary)}>{capitalize(member.membership as string)}</span>
</div>
</div>
</article>
)
}
export default MemberListItem

9
src/components/pages/directory/directory.tsx

@ -1,5 +1,7 @@
import React, { FC, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlusCircle } from '@fortawesome/free-solid-svg-icons'
import { setTitle } from 'src/utils'
import { Group } from 'src/types'
@ -32,7 +34,12 @@ const Directory: FC<Props> = ({ groups, fetchGroups }) => {
<br /><br />
<p className="has-text-centered">
<Link className="has-text-primary" to="/register">Create your own Community</Link>
<Link className="button is-primary" to="/register">
<span className="icon is-small">
<FontAwesomeIcon icon={faPlusCircle} />
</span>
<span>Create your own Community</span>
</Link>
</p>
</div>
</div>

130
src/components/pages/group-admin/group-admin.tsx

@ -0,0 +1,130 @@
import React, { FC, useEffect } from 'react'
import { RouteComponentProps, Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'
import { setTitle } from 'src/utils'
import { Group, GroupMembershipType, User } from 'src/types'
import PageHeader from 'src/components/page-header'
import MemberList from 'src/components/member-list'
interface Tab {
id: string
label: string
}
interface Params {
id: string
tab: string
}
export interface Props extends RouteComponentProps<Params> {
group?: Group
members?: User[]
fetchGroup: () => void
}
const GroupAdmin: FC<Props> = ({ group, members = [], fetchGroup, match, history }) => {
useEffect(() => {
fetchGroup()
}, [])
useEffect(() => {
if (group && group.membership) {
if (group.membership !== GroupMembershipType.Admin) {
history.push(`/c/${group.id}`)
return
}
setTitle(`${group.name} Administration`)
}
}, [group])
if (!group) {
return (
<div>
<PageHeader title="Group" />
<div className="main-content"></div>
</div>
)
}
const selectedTab = match.params.tab ? match.params.tab : ''
const tabs: Tab[] = [
{
id: '',
label: 'General',
},
{
id: 'members',
label: 'Members',
}
]
return (
<div>
<PageHeader title={group.name} subtitle="Administration" />
<div className="main-content">
<div className="centered-content">
<div className="tabs is-large">
<ul>
{tabs.map(t => (
<li key={t.id} className={selectedTab === t.id ? 'is-active': ''}>
<Link to={`/c/${group.id}/admin/${t.id}`}>
{t.label}
</Link>
</li>
))}
</ul>
</div>
<div className="container">
{selectedTab === '' &&
<div>
<div className="field">
<label className="label">ID</label>
<div className="control">
<input className="input" type="text" value={group.id} readOnly />
</div>
</div>
<br />
<div className="field">
<label className="label">Name</label>
<div className="control">
<input className="input" type="text" value={group.name} readOnly />
</div>
</div>
<br />
<div className="field">
<label className="label">About</label>
<div className="control">
<textarea className="textarea" placeholder="About This Community" value={group.about as string}></textarea>
</div>
</div>
<br />
<button className="button is-primary">
<span className="icon is-small">
<FontAwesomeIcon icon={faCheckCircle} />
</span>
<span>Save</span>
</button>
</div>
}
{match.params.tab === 'members' &&
<MemberList members={members} />
}
</div>
</div>
</div>
</div>
)
}
export default GroupAdmin

29
src/components/pages/group-admin/index.ts

@ -0,0 +1,29 @@
import { connect } from 'react-redux'
import { handleApiError } from 'src/api/errors'
import { fetchGroup, fetchGroupMembers } from 'src/actions/directory'
import { getGroupMembers } from 'src/selectors/directory'
import { getEntity } from 'src/selectors/entities'
import { AppState, EntityType, Group, AppThunkDispatch } from 'src/types'
import GroupAdmin, { Props } from './group-admin'
const mapStateToProps = (state: AppState, ownProps: Props) => ({
group: getEntity<Group>(state, EntityType.Group, ownProps.match.params.id),
members: getGroupMembers(state, ownProps.match.params.id),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
fetchGroup: () => {
try {
dispatch(fetchGroup(ownProps.match.params.id))
dispatch(fetchGroupMembers(ownProps.match.params.id))
} catch (err) {
handleApiError(err, dispatch, ownProps.history)
}
}
})
export default connect(
mapStateToProps,
mapDispatchToProps
)(GroupAdmin)

31
src/components/pages/group/group.tsx

@ -1,11 +1,11 @@
import React, { FC, useEffect } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import { Link, RouteComponentProps } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEdit } from '@fortawesome/free-solid-svg-icons'
import { faEdit, faUserCheck } from '@fortawesome/free-solid-svg-icons'
import { setTitle } from 'src/utils'
import { Group } from 'src/types'
import { Group, GroupMembershipType } from 'src/types'
import PageHeader from 'src/components/page-header'
import GroupInfo from 'src/components/group-info'
@ -19,7 +19,7 @@ export interface Props extends RouteComponentProps<Params> {
fetchGroup: () => void
}
const Self: FC<Props> = ({ group, fetchGroup }) => {
const GroupPage: FC<Props> = ({ group, fetchGroup }) => {
useEffect(() => {
fetchGroup()
}, [])
@ -37,6 +37,8 @@ const Self: FC<Props> = ({ group, fetchGroup }) => {
)
}
const isAdmin = group.membership === GroupMembershipType.Admin
return (
<div>
<PageHeader title={group.name} />
@ -45,19 +47,26 @@ const Self: FC<Props> = ({ group, fetchGroup }) => {
<GroupInfo group={group} />
<br /><br />
{group.membership === 'admin' &&
<div>
<button className="button is-warning">
<div className="buttons">
{isAdmin &&
<Link to={`/c/${group.id}/admin/`} className="button is-warning">
<span className="icon is-small">
<FontAwesomeIcon icon={faEdit} />
</span>
<span>Edit {group.name}</span>
</button>
</div>
}
</Link>
}
<Link to={`/c/${group.id}/register`} className="button is-success">
<span className="icon is-small">
<FontAwesomeIcon icon={faUserCheck} />
</span>
<span>Create an Account</span>
</Link>
</div>
</div>
</div>
)
}
export default Self
export default GroupPage

7
src/components/pages/login/login.tsx

@ -61,7 +61,12 @@ const Login: FC<Props> = ({
<PasswordField placeholder="Your password" showStrength={false} />
<br />
<button className={classNames(buttonClassDictionary)} onClick={() => authenticate(name, password)} disabled={authenticating}>Log In</button>
<button className={classNames(buttonClassDictionary)} onClick={() => authenticate(name, password)} disabled={authenticating}>
<span className="icon is-small">
<FontAwesomeIcon icon={faKey} />
</span>
<span>Log In</span>
</button>
</div>
</div>
</div>

15
src/components/pages/self/self.tsx

@ -1,8 +1,10 @@
import React, { FC, useEffect } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import moment from 'moment'
import { useAuthenticationCheck } from 'src/hooks'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDoorOpen } from '@fortawesome/free-solid-svg-icons'
import { useAuthenticationCheck } from 'src/hooks'
import { setTitle } from 'src/utils'
import { User } from 'src/types'
@ -38,15 +40,20 @@ const Self: FC<Props> = ({ checked, authenticated, user, logout, history }) => {
<div className="main-content">
<nav className="level">
<div className="level-item has-text-centered">
<p>
<div>
<div className="heading">Joined</div>
<div className="title">{moment(user.created).format('MMMM Do YYYY')}</div>
</p>
</div>
</div>
</nav>
<p>
<button className="button is-danger" onClick={() => logout()}>Log Out</button>
<button className="button is-danger" onClick={() => logout()}>
<span className="icon is-small">
<FontAwesomeIcon icon={faDoorOpen} />
</span>
<span>Log Out</span>
</button>
</p>
</div>
</div>

4
src/components/user-info/user-info.tsx

@ -20,8 +20,8 @@ const UserInfo: FC<Props> = ({ authenticated, user }) => {
if (user.name) {
return (
<>
<span className="is-size-4 has-text-white">{user.name}</span>
&nbsp;&nbsp;
<Link to="/self" className="is-size-4 has-text-white">{user.name}</Link>
<br />
<Link to="/self" className="is-size-6 has-text-white-ter">@{user.id}</Link>
</>
)

14
src/selectors/directory.ts

@ -1,15 +1,19 @@
import { denormalize } from 'normalizr'
import { createSelector } from 'reselect'
import filter from 'lodash/filter'
import { groupSchema } from '../store/schemas'
import { groupSchema, userSchema } from '../store/schemas'
import { getEntityStore } from './entities'
import { AppState, Group } from 'src/types'
import { AppState, Group, User, EntityType } from 'src/types'
export const getGroupIds = (state: AppState) => state.directory.groups
export const getGroups = createSelector(
[getEntityStore, getGroupIds],
(entities, groups) => {
return denormalize(groups, [groupSchema], entities) as Group[]
}
(entities, groups) => denormalize(groups, [groupSchema], entities) as Group[]
)
export const getGroupMembers = (state: AppState, group: string) => {
const users = state.entities[EntityType.User]
return denormalize(filter(users, user => user.group === group), [userSchema], state.entities) as User[]
}

7
src/types/entities.ts

@ -3,6 +3,12 @@ export enum EntityType {
Group = 'groups',
}
export enum GroupMembershipType {
Admin = 'admin',
Moderator = 'moderator',
Member = 'member',
}
export interface Entity {
[key: string]: string | number | boolean | object | any[]
id: string
@ -11,6 +17,7 @@ export interface Entity {
export type Group = Entity & {
name: string
membership?: GroupMembershipType
}
export type User = Entity & {

1
src/types/store.ts

@ -16,6 +16,7 @@ export enum RequestKey {
CreateGroup = 'create_group',
Register = 'register',
Authenticate = 'authenticate',
FetchGroupMembers = 'fetch_group_members',
}
export type FormValue = string | number | boolean

Loading…
Cancel
Save