Dwayne Harris
5 years ago
40 changed files with 1279 additions and 45 deletions
-
94package-lock.json
-
11package.json
-
28src/actions/authentication.ts
-
41src/actions/entities.ts
-
80src/actions/forms.ts
-
46src/actions/groups.ts
-
0src/actions/index.ts
-
17src/actions/menu.ts
-
36src/actions/notifications.ts
-
38src/actions/requests.ts
-
60src/api/errors.ts
-
136src/api/fetch.ts
-
20src/api/groups.ts
-
20src/components/app/app.scss
-
32src/components/app/app.tsx
-
26src/components/notification-container/index.ts
-
6src/components/notification-container/notification-container.scss
-
33src/components/notification-container/notification-container.tsx
-
34src/components/notification/index.tsx
-
20src/components/pages/directory/directory.tsx
-
21src/components/pages/directory/index.ts
-
13src/components/pages/login/index.tsx
-
2src/components/pages/login/login.scss
-
21src/components/pages/register/index.ts
-
20src/components/pages/register/register.tsx
-
14src/components/user-info/index.ts
-
17src/components/user-info/user-info.scss
-
43src/components/user-info/user-info.tsx
-
21src/config.ts
-
28src/reducers/authentication.ts
-
29src/reducers/entities.ts
-
74src/reducers/forms.ts
-
18src/reducers/index.ts
-
22src/reducers/menu.ts
-
28src/reducers/notifications.ts
-
39src/reducers/requests.ts
-
12src/selectors/index.ts
-
24src/store/index.ts
-
25src/types/entities.ts
-
75src/types/index.ts
@ -0,0 +1,28 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
|
||||
|
export interface SetAuthenticatedAction extends Action { |
||||
|
type: 'AUTHENTICATION_SET_AUTHENTICATED' |
||||
|
payload: boolean |
||||
|
} |
||||
|
|
||||
|
export interface SetUserAction extends Action { |
||||
|
type: 'AUTHENTICATION_SET_USER' |
||||
|
payload: string |
||||
|
} |
||||
|
|
||||
|
export type AuthenticationActions = SetAuthenticatedAction | SetUserAction |
||||
|
|
||||
|
const setAuthenticated = (authenticated: boolean): SetAuthenticatedAction => ({ |
||||
|
type: 'AUTHENTICATION_SET_AUTHENTICATED', |
||||
|
payload: authenticated, |
||||
|
}) |
||||
|
|
||||
|
const setUser = (userId: string): SetUserAction => ({ |
||||
|
type: 'AUTHENTICATION_SET_USER', |
||||
|
payload: userId, |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
setAuthenticated, |
||||
|
setUser, |
||||
|
} |
@ -0,0 +1,41 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
import { Entity } from '../types/entities' |
||||
|
|
||||
|
export interface SetEntityAction extends Action { |
||||
|
type: 'ENTITIES_SET_ENTITY' |
||||
|
payload: { |
||||
|
type: string |
||||
|
entity: Entity |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface SetEntitiesAction extends Action { |
||||
|
type: 'ENTITIES_SET_ENTITIES' |
||||
|
payload: { |
||||
|
type: string |
||||
|
entities: Entity[] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type EntitiesActions = SetEntityAction | SetEntitiesAction |
||||
|
|
||||
|
const setEntity = (type: string, entity: Entity): SetEntityAction => ({ |
||||
|
type: 'ENTITIES_SET_ENTITY', |
||||
|
payload: { |
||||
|
type, |
||||
|
entity, |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
const setEntities = (type: string, entities: Entity[]): SetEntitiesAction => ({ |
||||
|
type: 'ENTITIES_SET_ENTITIES', |
||||
|
payload: { |
||||
|
type, |
||||
|
entities, |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
setEntity, |
||||
|
setEntities, |
||||
|
} |
@ -0,0 +1,80 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
import { NotificationType } from '../types' |
||||
|
|
||||
|
export interface InitFormAction extends Action { |
||||
|
type: 'FORMS_INIT' |
||||
|
} |
||||
|
|
||||
|
export interface InitFieldAction extends Action { |
||||
|
type: 'FORMS_INIT_FIELD' |
||||
|
payload: string |
||||
|
} |
||||
|
|
||||
|
export interface SetFieldValueAction extends Action { |
||||
|
type: 'FORMS_SET_FIELD_VALUE' |
||||
|
payload: { |
||||
|
name: string |
||||
|
value: any |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface SetFormNotificationAction extends Action { |
||||
|
type: 'FORMS_SET_FORM_NOTIFICATION' |
||||
|
payload: { |
||||
|
type: NotificationType |
||||
|
message: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface SetFieldNotificationAction extends Action { |
||||
|
type: 'FORMS_SET_FIELD_NOTIFICATION' |
||||
|
payload: { |
||||
|
name: string |
||||
|
type: NotificationType |
||||
|
message: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type FormsActions = InitFormAction | InitFieldAction | SetFieldValueAction | SetFormNotificationAction | SetFieldNotificationAction |
||||
|
|
||||
|
const initForm = (): InitFormAction => ({ |
||||
|
type: 'FORMS_INIT', |
||||
|
}) |
||||
|
|
||||
|
const initField = (name: string): InitFieldAction => ({ |
||||
|
type: 'FORMS_INIT_FIELD', |
||||
|
payload: name, |
||||
|
}) |
||||
|
|
||||
|
const setFieldValue = (name: string, value: any): SetFieldValueAction => ({ |
||||
|
type: 'FORMS_SET_FIELD_VALUE', |
||||
|
payload: { |
||||
|
name, |
||||
|
value, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const setFormNotification = (type: NotificationType, message: string): SetFormNotificationAction => ({ |
||||
|
type: 'FORMS_SET_FORM_NOTIFICATION', |
||||
|
payload: { |
||||
|
type, |
||||
|
message, |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
const setFieldNotification = (name: string, type: NotificationType, message: string): SetFieldNotificationAction => ({ |
||||
|
type: 'FORMS_SET_FIELD_NOTIFICATION', |
||||
|
payload: { |
||||
|
name, |
||||
|
type, |
||||
|
message, |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
initForm, |
||||
|
initField, |
||||
|
setFieldValue, |
||||
|
setFormNotification, |
||||
|
setFieldNotification, |
||||
|
} |
@ -0,0 +1,46 @@ |
|||||
|
import { ThunkAction, ThunkDispatch } from 'redux-thunk' |
||||
|
import { Action, AnyAction } from 'redux' |
||||
|
|
||||
|
import { getGroups } from '../api/groups' |
||||
|
import { startRequest, finishRequest } from '../actions/requests' |
||||
|
|
||||
|
import { AppState } from '../types' |
||||
|
import { Entity } from '../types/entities' |
||||
|
|
||||
|
const FETCH_ID = 'groups' |
||||
|
|
||||
|
export interface FetchGroupsAction extends Action { |
||||
|
type: 'GROUPS_FETCH' |
||||
|
payload: { |
||||
|
groups: Entity[] |
||||
|
continuation?: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const setGroups = (groups: Entity[], continuation?: string): FetchGroupsAction => ({ |
||||
|
type: 'GROUPS_FETCH', |
||||
|
payload: { |
||||
|
groups, |
||||
|
continuation, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const fetchGroups = (sort?: string, continuation?: string): ThunkAction<Promise<void>, AppState, void, AnyAction> => { |
||||
|
return async (dispatch: ThunkDispatch<AppState, void, AnyAction>) => { |
||||
|
dispatch(startRequest(FETCH_ID)) |
||||
|
|
||||
|
try { |
||||
|
const response = await getGroups(sort, continuation) |
||||
|
|
||||
|
dispatch(setGroups(response.groups, response.continuation)) |
||||
|
dispatch(finishRequest(FETCH_ID, true)) |
||||
|
} catch (err) { |
||||
|
console.error(err) |
||||
|
dispatch(finishRequest(FETCH_ID, false)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
fetchGroups, |
||||
|
} |
@ -0,0 +1,17 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
|
||||
|
export interface SetCollapsedAction extends Action { |
||||
|
type: 'MENU_SET_COLLAPSED' |
||||
|
payload: boolean |
||||
|
} |
||||
|
|
||||
|
export type MenuActions = SetCollapsedAction |
||||
|
|
||||
|
const setCollapsed = (collapsed: boolean): SetCollapsedAction => ({ |
||||
|
type: 'MENU_SET_COLLAPSED', |
||||
|
payload: collapsed |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
setCollapsed, |
||||
|
} |
@ -0,0 +1,36 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
|
||||
|
export interface SetAutoAction extends Action { |
||||
|
type: 'NOTIFICATIONS_SET_AUTO' |
||||
|
payload: { |
||||
|
id: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface DismissAction extends Action { |
||||
|
type: 'NOTIFICATIONS_DISMISS' |
||||
|
payload: { |
||||
|
id: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type NotificationActions = SetAutoAction | DismissAction |
||||
|
|
||||
|
const setAuto = (id: string): SetAutoAction => ({ |
||||
|
type: 'NOTIFICATIONS_SET_AUTO', |
||||
|
payload: { |
||||
|
id, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const dismiss = (id: string): DismissAction => ({ |
||||
|
type: 'NOTIFICATIONS_DISMISS', |
||||
|
payload: { |
||||
|
id, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
setAuto, |
||||
|
dismiss, |
||||
|
} |
@ -0,0 +1,38 @@ |
|||||
|
import { Action } from 'redux' |
||||
|
|
||||
|
export interface StartRequestAction extends Action { |
||||
|
type: 'REQUESTS_START_REQUEST' |
||||
|
payload: { |
||||
|
id: string |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface FinishRequestAction extends Action { |
||||
|
type: 'REQUESTS_FINISH_REQUEST' |
||||
|
payload: { |
||||
|
id: string |
||||
|
succeeded: boolean |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type RequestsActions = StartRequestAction | FinishRequestAction |
||||
|
|
||||
|
const startRequest = (id: string): StartRequestAction => ({ |
||||
|
type: 'REQUESTS_START_REQUEST', |
||||
|
payload: { |
||||
|
id, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const finishRequest = (id: string, succeeded: boolean): FinishRequestAction => ({ |
||||
|
type: 'REQUESTS_FINISH_REQUEST', |
||||
|
payload: { |
||||
|
id, |
||||
|
succeeded, |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
export { |
||||
|
startRequest, |
||||
|
finishRequest, |
||||
|
} |
@ -0,0 +1,60 @@ |
|||||
|
import { IFormNotification } from '../types' |
||||
|
|
||||
|
export class HttpError extends Error { |
||||
|
statusCode: number |
||||
|
errors: IFormNotification[] |
||||
|
|
||||
|
constructor(message = 'Unknown Error') { |
||||
|
super(message) |
||||
|
|
||||
|
this.name = 'HttpError' |
||||
|
this.statusCode = 500 |
||||
|
this.errors = [] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class ServerError extends HttpError { |
||||
|
constructor() { |
||||
|
super('Server Error') |
||||
|
|
||||
|
this.name = 'ServerError' |
||||
|
this.statusCode = 500 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class BadRequestError extends HttpError { |
||||
|
constructor(message = 'Bad Request', errors: IFormNotification[] = []) { |
||||
|
super(message) |
||||
|
|
||||
|
this.name = 'BadRequestError' |
||||
|
this.statusCode = 400 |
||||
|
this.errors = errors |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class UnauthorizedError extends HttpError { |
||||
|
constructor(message = 'Unauthorized') { |
||||
|
super(message) |
||||
|
|
||||
|
this.name = 'UnauthorizedError' |
||||
|
this.statusCode = 401 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class ForbiddenError extends HttpError { |
||||
|
constructor(message = 'Forbidden') { |
||||
|
super(message) |
||||
|
|
||||
|
this.name = 'ForbiddenError' |
||||
|
this.statusCode = 403 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class NotFoundError extends HttpError { |
||||
|
constructor(message = 'Not Found') { |
||||
|
super(message) |
||||
|
|
||||
|
this.name = 'NotFoundError' |
||||
|
this.statusCode = 404 |
||||
|
} |
||||
|
} |
@ -0,0 +1,136 @@ |
|||||
|
import { |
||||
|
UnauthorizedError, |
||||
|
BadRequestError, |
||||
|
NotFoundError, |
||||
|
ServerError, |
||||
|
} from './errors' |
||||
|
import { FetchOptions, FormNotification } from '../types' |
||||
|
import getConfig from '../config' |
||||
|
|
||||
|
interface RefreshResponse { |
||||
|
id: string |
||||
|
access: string |
||||
|
refresh: string |
||||
|
expires: number |
||||
|
} |
||||
|
|
||||
|
interface FormError { |
||||
|
field?: string |
||||
|
message: string |
||||
|
} |
||||
|
|
||||
|
interface ErrorResponse { |
||||
|
message: string |
||||
|
errors?: FormError[] |
||||
|
} |
||||
|
|
||||
|
type APIFetch = <T>(options: FetchOptions) => Promise<T> |
||||
|
|
||||
|
const mapErrorsToFormNotifications = (errors?: FormError[]): FormNotification[] => { |
||||
|
if (!errors) return [] |
||||
|
|
||||
|
return errors.map(e => ({ |
||||
|
field: e.field, |
||||
|
type: 'error', |
||||
|
message: e.message, |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
const checkResponse = async (response: Response, retry?: () => Promise<void>) => { |
||||
|
switch (response.status) { |
||||
|
case 400: { |
||||
|
const { message, errors } = await response.json() as ErrorResponse |
||||
|
throw new BadRequestError(message, mapErrorsToFormNotifications(errors)) |
||||
|
} |
||||
|
case 401: { |
||||
|
if (retry) return await retry() |
||||
|
throw new UnauthorizedError() |
||||
|
} |
||||
|
case 404: throw new NotFoundError() |
||||
|
default: { |
||||
|
throw new ServerError() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const getResponseData = async (response: Response) => { |
||||
|
try { |
||||
|
return await response.json() |
||||
|
} catch (err) { |
||||
|
return {} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const apiFetch: APIFetch = async (options: FetchOptions) => { |
||||
|
const { path, method = 'get', body } = options |
||||
|
const contentType = 'application/json' |
||||
|
const config = await getConfig() |
||||
|
|
||||
|
const doFetch = async () => { |
||||
|
const headers = new Headers({ |
||||
|
...options.headers, |
||||
|
'Content-Type': contentType, |
||||
|
'Accept': contentType, |
||||
|
}) |
||||
|
|
||||
|
const accessToken = localStorage.getItem('accessToken') |
||||
|
if (accessToken) headers.append('Authorization', `Bearer ${accessToken}`) |
||||
|
|
||||
|
return await fetch(`${config.apiUrl}${path}`, { |
||||
|
headers, |
||||
|
method, |
||||
|
body: body ? JSON.stringify(body) : undefined, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const doRefresh = async () => { |
||||
|
const accessToken = localStorage.getItem('accessToken') |
||||
|
const refreshToken = localStorage.getItem('refreshToken') |
||||
|
|
||||
|
if (accessToken && refreshToken) { |
||||
|
const refreshResponse = await fetch('/api/refresh', { |
||||
|
headers: new Headers({ |
||||
|
'Content-Type': contentType, |
||||
|
'Authorization': `Bearer ${accessToken}` |
||||
|
}), |
||||
|
method: 'post', |
||||
|
body: JSON.stringify({ |
||||
|
refresh: refreshToken, |
||||
|
}), |
||||
|
}) |
||||
|
|
||||
|
if (refreshResponse.status !== 201) { |
||||
|
throw new UnauthorizedError() |
||||
|
} |
||||
|
|
||||
|
const data = await getResponseData(refreshResponse) as RefreshResponse |
||||
|
|
||||
|
localStorage.setItem('accessToken', data.access) |
||||
|
localStorage.setItem('accessTokenExpiresAt', data.expires.toString()) |
||||
|
localStorage.setItem('refeshToken', data.refresh) |
||||
|
|
||||
|
const secondResponse = await doFetch() |
||||
|
if (secondResponse.ok) { |
||||
|
return await getResponseData(secondResponse) |
||||
|
} |
||||
|
|
||||
|
await checkResponse(secondResponse) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const accessTokenExpiresAt = localStorage.getItem('accessTokenExpiresAt') |
||||
|
if (accessTokenExpiresAt && Date.now() >= parseInt(accessTokenExpiresAt, 10)) { |
||||
|
return await doRefresh() |
||||
|
} |
||||
|
|
||||
|
const response = await doFetch() |
||||
|
if (response.ok) { |
||||
|
return await getResponseData(response) |
||||
|
} |
||||
|
|
||||
|
return await checkResponse(response, doRefresh) |
||||
|
} |
||||
|
|
||||
|
export { |
||||
|
apiFetch as fetch, |
||||
|
} |
@ -0,0 +1,20 @@ |
|||||
|
import { fetch } from './fetch' |
||||
|
import { Entity } from '../types/entities' |
||||
|
|
||||
|
interface GroupsResponse { |
||||
|
groups: Entity[] |
||||
|
continuation?: string |
||||
|
} |
||||
|
|
||||
|
export async function getGroups(sort: string = 'members', continuation?: string) { |
||||
|
const params = { |
||||
|
sort, |
||||
|
continuation, |
||||
|
} |
||||
|
|
||||
|
const querystring = Object.entries(params).filter(([name, value]) => value !== undefined).map(([name, value]) => `${name}=${value}`).join('&') |
||||
|
|
||||
|
return await fetch<GroupsResponse>({ |
||||
|
path: `/api/groups?${querystring}` |
||||
|
}) |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
import { Dispatch } from 'redux' |
||||
|
import { connect } from 'react-redux' |
||||
|
|
||||
|
import { setAuto, dismiss } from '../../actions/notifications' |
||||
|
import { getNotifications } from '../../selectors' |
||||
|
import { AppState } from '../../types' |
||||
|
|
||||
|
import NotificationContainer from './notification-container' |
||||
|
|
||||
|
const mapStateToProps = (state: AppState) => ({ |
||||
|
notifications: getNotifications(state), |
||||
|
}) |
||||
|
|
||||
|
const mapDispatchToProps = (dispatch: Dispatch) => ({ |
||||
|
setAuto: (id: string) => { |
||||
|
dispatch(setAuto(id)) |
||||
|
}, |
||||
|
dismiss: (id: string) => { |
||||
|
dispatch(dismiss(id)) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export default connect( |
||||
|
mapStateToProps, |
||||
|
mapDispatchToProps |
||||
|
)(NotificationContainer) |
@ -0,0 +1,6 @@ |
|||||
|
div#notification-container { |
||||
|
bottom: 0; |
||||
|
position: absolute; |
||||
|
right: 0; |
||||
|
width: 25%; |
||||
|
} |
@ -0,0 +1,33 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
import { Notification as INotification } from '../../types' |
||||
|
|
||||
|
import Notification from '../notification' |
||||
|
|
||||
|
import './notification-container.scss' |
||||
|
|
||||
|
interface Props { |
||||
|
notifications: INotification[] |
||||
|
setAuto: (id: string) => void |
||||
|
dismiss: (id: string) => void |
||||
|
} |
||||
|
|
||||
|
const NotificationContainer: FC<Props> = ({ notifications, setAuto, dismiss }) => { |
||||
|
return ( |
||||
|
<div id="notification-container"> |
||||
|
{notifications.map(notification => { |
||||
|
return ( |
||||
|
<Notification |
||||
|
id={notification.id} |
||||
|
type={notification.type} |
||||
|
auto={notification.auto} |
||||
|
setAuto={setAuto} |
||||
|
dismiss={dismiss}> |
||||
|
{notification.content} |
||||
|
</Notification> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default NotificationContainer |
@ -0,0 +1,34 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
import { NotificationType } from '../../types' |
||||
|
|
||||
|
interface Props { |
||||
|
id: string |
||||
|
type: NotificationType |
||||
|
auto: boolean |
||||
|
setAuto: (id: string) => void |
||||
|
dismiss: (id: string) => void |
||||
|
} |
||||
|
|
||||
|
const getClassName = (type: NotificationType) => { |
||||
|
switch (type) { |
||||
|
case 'info': return 'is-info' |
||||
|
case 'success': return 'is-success' |
||||
|
case 'error': return 'is-danger' |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const Notification: FC<Props> = ({ id, type, auto, setAuto, dismiss, children }) => { |
||||
|
const classnames = [ |
||||
|
'notification', |
||||
|
getClassName(type), |
||||
|
].join(' ') |
||||
|
|
||||
|
return ( |
||||
|
<div className={classnames} onClick={() => setAuto(id)}> |
||||
|
{!auto && <button className="delete" onClick={() => dismiss(id)}></button>} |
||||
|
{children} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default Notification |
@ -0,0 +1,20 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
|
||||
|
import { Entity } from '../../../types/entities' |
||||
|
|
||||
|
interface Props { |
||||
|
groups: Entity[] |
||||
|
fetchGroups: () => void |
||||
|
} |
||||
|
|
||||
|
const Directory: FC<Props> = ({ groups, fetchGroups }) => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h1 className="title">Communities</h1> |
||||
|
|
||||
|
{groups.length === 0 && <p>No Communities</p>} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default Directory |
@ -0,0 +1,21 @@ |
|||||
|
import { connect } from 'react-redux' |
||||
|
import { ThunkDispatch } from 'redux-thunk' |
||||
|
|
||||
|
import { fetchGroups, FetchGroupsAction } from '../../../actions/groups' |
||||
|
import { AppState } from '../../../types' |
||||
|
|
||||
|
import Directory from './directory' |
||||
|
|
||||
|
const mapStateToProps = () => { |
||||
|
} |
||||
|
|
||||
|
const mapDispatchToProps = (dispatch: ThunkDispatch<AppState, void, FetchGroupsAction>) => ({ |
||||
|
fetchGroups: () => { |
||||
|
dispatch(fetchGroups()) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export default connect( |
||||
|
mapStateToProps, |
||||
|
mapDispatchToProps |
||||
|
)(Directory) |
@ -0,0 +1,13 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
|
||||
|
import './login.scss' |
||||
|
|
||||
|
const Login: FC = () => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h1 className="title">Login</h1> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default Login |
@ -0,0 +1,2 @@ |
|||||
|
@import "../../../../node_modules/bulma/sass/utilities/_all.sass"; |
||||
|
@import "../../../../node_modules/bulma/sass/form/_all.sass"; |
@ -0,0 +1,21 @@ |
|||||
|
import { connect } from 'react-redux' |
||||
|
import { ThunkDispatch } from 'redux-thunk' |
||||
|
|
||||
|
import { fetchGroups, FetchGroupsAction } from '../../../actions/groups' |
||||
|
import { AppState } from '../../../types' |
||||
|
|
||||
|
import Register from './register' |
||||
|
|
||||
|
const mapStateToProps = () => { |
||||
|
} |
||||
|
|
||||
|
const mapDispatchToProps = (dispatch: ThunkDispatch<AppState, void, FetchGroupsAction>) => ({ |
||||
|
fetchGroups: () => { |
||||
|
dispatch(fetchGroups()) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
export default connect( |
||||
|
mapStateToProps, |
||||
|
mapDispatchToProps |
||||
|
)(Register) |
@ -0,0 +1,20 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
|
||||
|
import { Entity } from '../../../types/entities' |
||||
|
|
||||
|
interface Props { |
||||
|
groups: Entity[] |
||||
|
fetchGroups: () => void |
||||
|
} |
||||
|
|
||||
|
const Register: FC<Props> = ({ groups, fetchGroups }) => { |
||||
|
return ( |
||||
|
<div> |
||||
|
<h1 className="title">Communities</h1> |
||||
|
|
||||
|
{groups.length === 0 && <p>No Groups</p>} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default Register |
@ -0,0 +1,14 @@ |
|||||
|
import { connect } from 'react-redux' |
||||
|
|
||||
|
import { getAuthenticated } from '../../selectors' |
||||
|
import { IAppState } from '../../types' |
||||
|
|
||||
|
import UserInfo from './user-info' |
||||
|
|
||||
|
const mapStateToProps = (state: IAppState) => ({ |
||||
|
authenticated: getAuthenticated(state), |
||||
|
}) |
||||
|
|
||||
|
export default connect( |
||||
|
mapStateToProps |
||||
|
)(UserInfo) |
@ -0,0 +1,17 @@ |
|||||
|
@import "../../../node_modules/bulma/sass/utilities/_all.sass"; |
||||
|
@import "../../../node_modules/bulma/sass/components/media.sass"; |
||||
|
|
||||
|
$image-size: 64px; |
||||
|
|
||||
|
div.empty-image { |
||||
|
background-color: #eeeeee; |
||||
|
background-image: linear-gradient(145deg, #eeeeee, #aaaaaa); |
||||
|
border: solid 1px #cccccc; |
||||
|
border-radius: $image-size; |
||||
|
height: $image-size; |
||||
|
width: $image-size; |
||||
|
} |
||||
|
|
||||
|
article#user-info { |
||||
|
padding: 20px; |
||||
|
} |
@ -0,0 +1,43 @@ |
|||||
|
import React, { FC } from 'react' |
||||
|
import { Link } from 'react-router-dom' |
||||
|
|
||||
|
import { IUser } from '../../types/entities' |
||||
|
|
||||
|
import './user-info.scss' |
||||
|
|
||||
|
interface Props { |
||||
|
authenticated: boolean |
||||
|
user?: IUser |
||||
|
} |
||||
|
|
||||
|
const UserInfo: FC<Props> = ({ authenticated, user }) => { |
||||
|
const getAvatar = () => { |
||||
|
if (authenticated && user && user.imageUrl) { |
||||
|
return <img src={user.imageUrl} /> |
||||
|
} |
||||
|
|
||||
|
return <div className="empty-image"></div> |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<article id="user-info" className="media has-background-black"> |
||||
|
<figure className="media-left"> |
||||
|
<p className="image is-64x64"> |
||||
|
{getAvatar()} |
||||
|
</p> |
||||
|
</figure> |
||||
|
|
||||
|
<div className="media-content"> |
||||
|
<div className="content"> |
||||
|
<p> |
||||
|
<Link to="/login" className="is-size-5 has-text-white has-text-weight-bold">Log In</Link> |
||||
|
<br /> |
||||
|
<Link to="/signup" className="is-size-7 has-text-primary">Sign Up</Link> |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</article> |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export default UserInfo |
@ -0,0 +1,21 @@ |
|||||
|
interface Config { |
||||
|
apiUrl: string |
||||
|
} |
||||
|
|
||||
|
declare global { |
||||
|
interface Window { |
||||
|
flexorConfig?: Config |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default async function getConfig(): Promise<Config> { |
||||
|
if (window.flexorConfig) return window.flexorConfig |
||||
|
|
||||
|
const response = await fetch('/config.json') |
||||
|
if (!response.ok) throw new Error() |
||||
|
|
||||
|
const config = await response.json() |
||||
|
window.flexorConfig = config |
||||
|
|
||||
|
return config |
||||
|
} |
@ -0,0 +1,28 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
|
||||
|
import { AuthenticationActions } from '../actions/authentication' |
||||
|
import { AuthenticationState } from '../types' |
||||
|
|
||||
|
const initialState: AuthenticationState = { |
||||
|
authenticated: false, |
||||
|
userId: undefined, |
||||
|
} |
||||
|
|
||||
|
const reducer: Reducer<AuthenticationState, AuthenticationActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'AUTHENTICATION_SET_AUTHENTICATED': |
||||
|
return { |
||||
|
...state, |
||||
|
authenticated: action.payload, |
||||
|
} |
||||
|
case 'AUTHENTICATION_SET_USER': |
||||
|
return { |
||||
|
...state, |
||||
|
userId: action.payload, |
||||
|
} |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -0,0 +1,29 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
import merge from 'lodash/merge' |
||||
|
|
||||
|
import { EntitiesActions } from '../actions/entities' |
||||
|
import { EntitiesState } from '../types' |
||||
|
|
||||
|
const initialState: EntitiesState = {} |
||||
|
|
||||
|
const reducer: Reducer<EntitiesState, EntitiesActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'ENTITIES_SET_ENTITY': { |
||||
|
const { type, entity } = action.payload |
||||
|
const collection = state[type] || {} |
||||
|
const existing = collection[entity.id] || {} |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
[type]: { |
||||
|
...collection, |
||||
|
[entity.id]: merge(existing, entity), |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -0,0 +1,74 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
|
||||
|
import { FormsActions } from '../actions/forms' |
||||
|
import { FormsState } from '../types' |
||||
|
|
||||
|
const initialState: FormsState = { |
||||
|
form: {}, |
||||
|
notification: undefined, |
||||
|
} |
||||
|
|
||||
|
const reducer: Reducer<FormsState, FormsActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'FORMS_INIT': |
||||
|
return { |
||||
|
form: {}, |
||||
|
notification: undefined, |
||||
|
} |
||||
|
case 'FORMS_INIT_FIELD': |
||||
|
return { |
||||
|
...state, |
||||
|
form: { |
||||
|
...state.form, |
||||
|
[action.payload]: { |
||||
|
name: action.payload, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
case 'FORMS_SET_FIELD_VALUE': { |
||||
|
const field = state.form[action.payload.name] |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
form: { |
||||
|
...state.form, |
||||
|
[action.payload.name]: { |
||||
|
...field, |
||||
|
value: action.payload.value, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
case 'FORMS_SET_FORM_NOTIFICATION': |
||||
|
const { type, message } = action.payload |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
notification: { |
||||
|
type, |
||||
|
message, |
||||
|
}, |
||||
|
} |
||||
|
case 'FORMS_SET_FIELD_NOTIFICATION': { |
||||
|
const field = state.form[action.payload.name] |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
form: { |
||||
|
...state.form, |
||||
|
[action.payload.name]: { |
||||
|
...field, |
||||
|
notification: { |
||||
|
type, |
||||
|
message, |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -1,18 +0,0 @@ |
|||||
import { Reducer, AnyAction } from 'redux' |
|
||||
import { IAppState } from '../types' |
|
||||
|
|
||||
const initialState: IAppState = { |
|
||||
menuCollapsed: false, |
|
||||
fetching: false, |
|
||||
authenticated: false, |
|
||||
notifications: [], |
|
||||
} |
|
||||
|
|
||||
const reducer: Reducer<IAppState, AnyAction> = (state = initialState, action) => { |
|
||||
switch (action.type) { |
|
||||
default: |
|
||||
return state |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
export default reducer |
|
@ -0,0 +1,22 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
|
||||
|
import { MenuActions } from '../actions/menu' |
||||
|
import { MenuState } from '../types' |
||||
|
|
||||
|
const initialState: MenuState = { |
||||
|
collapsed: false, |
||||
|
} |
||||
|
|
||||
|
const reducer: Reducer<MenuState, MenuActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'MENU_SET_COLLAPSED': |
||||
|
return { |
||||
|
...state, |
||||
|
collapsed: action.payload, |
||||
|
} |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -0,0 +1,28 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
|
||||
|
import { NotificationActions } from '../actions/notifications' |
||||
|
import { NotificationsState } from '../types' |
||||
|
|
||||
|
const initialState: NotificationsState = [] |
||||
|
|
||||
|
const reducer: Reducer<NotificationsState, NotificationActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'NOTIFICATIONS_SET_AUTO': |
||||
|
return state.map(notification => { |
||||
|
if (notification.id === action.payload.id) { |
||||
|
return { |
||||
|
...notification, |
||||
|
auto: false, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return notification |
||||
|
}) |
||||
|
case 'NOTIFICATIONS_DISMISS': |
||||
|
return state.filter(notification => notification.id !== action.payload.id) |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -0,0 +1,39 @@ |
|||||
|
import { Reducer } from 'redux' |
||||
|
|
||||
|
import { RequestsActions } from '../actions/requests' |
||||
|
import { RequestsState } from '../types' |
||||
|
|
||||
|
const initialState: RequestsState = {} |
||||
|
|
||||
|
const reducer: Reducer<RequestsState, RequestsActions> = (state = initialState, action) => { |
||||
|
switch (action.type) { |
||||
|
case 'REQUESTS_START_REQUEST': { |
||||
|
const request = state[action.payload.id] || {} |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
[action.payload.id]: { |
||||
|
...request, |
||||
|
fetching: true, |
||||
|
succeeded: false, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
case 'REQUESTS_FINISH_REQUEST': { |
||||
|
const request = state[action.payload.id] || {} |
||||
|
|
||||
|
return { |
||||
|
...state, |
||||
|
[action.payload.id]: { |
||||
|
...request, |
||||
|
fetching: false, |
||||
|
succeeded: action.payload.succeeded, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
default: |
||||
|
return state |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default reducer |
@ -1,4 +1,10 @@ |
|||||
import { IAppState } from '../types' |
|
||||
|
import values from 'lodash/values' |
||||
|
import { AppState } from '../types' |
||||
|
|
||||
export const getMenuCollapsed = (state: IAppState) => state.menuCollapsed |
|
||||
export const getFetching = (state: IAppState) => state.fetching |
|
||||
|
export const getMenuCollapsed = (state: AppState) => state.menu.collapsed |
||||
|
export const getAuthenticated = (state: AppState) => state.authentication.authenticated |
||||
|
export const getNotifications = (state: AppState) => state.notifications |
||||
|
|
||||
|
export const getFetching = (state: AppState) => { |
||||
|
return values(state.requests).reduce((fetching, request) => fetching || request.fetching, false) |
||||
|
} |
@ -1,6 +1,24 @@ |
|||||
import { createStore } from 'redux' |
|
||||
import appReducer from '../reducers' |
|
||||
|
import { createStore, combineReducers, applyMiddleware } from 'redux' |
||||
|
import { AppState } from '../types' |
||||
|
|
||||
const store = createStore(appReducer) |
|
||||
|
import authentication from '../reducers/authentication' |
||||
|
import entities from '../reducers/entities' |
||||
|
import menu from '../reducers/menu' |
||||
|
import notifications from '../reducers/notifications' |
||||
|
import requests from '../reducers/requests' |
||||
|
|
||||
|
import logger from 'redux-logger' |
||||
|
import thunk from 'redux-thunk' |
||||
|
|
||||
|
const store = createStore( |
||||
|
combineReducers<AppState>({ |
||||
|
authentication, |
||||
|
entities, |
||||
|
menu, |
||||
|
notifications, |
||||
|
requests, |
||||
|
}), |
||||
|
applyMiddleware(logger, thunk) |
||||
|
) |
||||
|
|
||||
export default store |
export default store |
@ -0,0 +1,25 @@ |
|||||
|
export interface Entity { |
||||
|
[key: string]: string | number | boolean | object | any[] |
||||
|
id: string |
||||
|
created: number |
||||
|
} |
||||
|
|
||||
|
export type Group = Entity & { |
||||
|
name: string |
||||
|
} |
||||
|
|
||||
|
export type User = Entity & { |
||||
|
name: string |
||||
|
group?: Group |
||||
|
about?: string |
||||
|
imageUrl?: string |
||||
|
coverImageUrl?: string |
||||
|
} |
||||
|
|
||||
|
export interface EntityCollection { |
||||
|
[id: string]: Entity |
||||
|
} |
||||
|
|
||||
|
export interface EntityStore { |
||||
|
[type: string]: EntityCollection |
||||
|
} |
@ -1,13 +1,72 @@ |
|||||
export type INotificationType = 'info' | 'success' | 'error' |
|
||||
|
import { EntityStore } from './entities' |
||||
|
|
||||
export interface INotification { |
|
||||
type: INotificationType |
|
||||
|
export type NotificationType = 'info' | 'success' | 'error' |
||||
|
export type FetchMethods = 'get' | 'post' | 'put' |
||||
|
|
||||
|
export interface FetchOptions { |
||||
|
path: string |
||||
|
method?: FetchMethods |
||||
|
body?: object |
||||
|
headers?: HeadersInit |
||||
|
} |
||||
|
|
||||
|
export interface FormNotification { |
||||
|
field?: string |
||||
|
type: NotificationType |
||||
message: string |
message: string |
||||
} |
} |
||||
|
|
||||
export interface IAppState { |
|
||||
menuCollapsed: boolean |
|
||||
fetching: boolean |
|
||||
authenticated: boolean |
|
||||
notifications: INotification[] |
|
||||
|
export interface APIRequest { |
||||
|
readonly id: string |
||||
|
readonly fetching: boolean |
||||
|
readonly succeeded: boolean |
||||
|
} |
||||
|
|
||||
|
export interface APIRequestCollection { |
||||
|
[id: string]: APIRequest |
||||
|
} |
||||
|
|
||||
|
export interface Notification { |
||||
|
readonly id: string |
||||
|
readonly type: NotificationType |
||||
|
readonly content: string |
||||
|
readonly auto: boolean |
||||
|
readonly expiration: number |
||||
|
} |
||||
|
|
||||
|
export interface AuthenticationState { |
||||
|
readonly authenticated: boolean |
||||
|
readonly userId?: string |
||||
|
} |
||||
|
|
||||
|
export interface MenuState { |
||||
|
readonly collapsed: boolean |
||||
|
} |
||||
|
|
||||
|
export interface FormField { |
||||
|
readonly name: string |
||||
|
readonly value?: string |
||||
|
readonly notification?: FormNotification |
||||
|
} |
||||
|
|
||||
|
export interface Form { |
||||
|
[name: string]: FormField |
||||
|
} |
||||
|
|
||||
|
export interface FormsState { |
||||
|
readonly form: Form |
||||
|
readonly notification?: FormNotification |
||||
|
} |
||||
|
|
||||
|
export type RequestsState = APIRequestCollection |
||||
|
export type NotificationsState = readonly Notification[] |
||||
|
export type EntitiesState = EntityStore |
||||
|
|
||||
|
export interface AppState { |
||||
|
authentication: AuthenticationState |
||||
|
entities: EntitiesState |
||||
|
forms: FormsState |
||||
|
menu: MenuState |
||||
|
notifications: NotificationsState |
||||
|
requests: RequestsState |
||||
} |
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue