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