Dwayne Harris 5 years ago
parent
commit
6bf37f97cb
  1. 94
      package-lock.json
  2. 11
      package.json
  3. 28
      src/actions/authentication.ts
  4. 41
      src/actions/entities.ts
  5. 80
      src/actions/forms.ts
  6. 46
      src/actions/groups.ts
  7. 0
      src/actions/index.ts
  8. 17
      src/actions/menu.ts
  9. 36
      src/actions/notifications.ts
  10. 38
      src/actions/requests.ts
  11. 60
      src/api/errors.ts
  12. 136
      src/api/fetch.ts
  13. 20
      src/api/groups.ts
  14. 20
      src/components/app/app.scss
  15. 32
      src/components/app/app.tsx
  16. 26
      src/components/notification-container/index.ts
  17. 6
      src/components/notification-container/notification-container.scss
  18. 33
      src/components/notification-container/notification-container.tsx
  19. 34
      src/components/notification/index.tsx
  20. 20
      src/components/pages/directory/directory.tsx
  21. 21
      src/components/pages/directory/index.ts
  22. 13
      src/components/pages/login/index.tsx
  23. 2
      src/components/pages/login/login.scss
  24. 21
      src/components/pages/register/index.ts
  25. 20
      src/components/pages/register/register.tsx
  26. 14
      src/components/user-info/index.ts
  27. 17
      src/components/user-info/user-info.scss
  28. 43
      src/components/user-info/user-info.tsx
  29. 21
      src/config.ts
  30. 28
      src/reducers/authentication.ts
  31. 29
      src/reducers/entities.ts
  32. 74
      src/reducers/forms.ts
  33. 18
      src/reducers/index.ts
  34. 22
      src/reducers/menu.ts
  35. 28
      src/reducers/notifications.ts
  36. 39
      src/reducers/requests.ts
  37. 12
      src/selectors/index.ts
  38. 24
      src/store/index.ts
  39. 25
      src/types/entities.ts
  40. 75
      src/types/index.ts

94
package-lock.json

@ -12,6 +12,36 @@
"regenerator-runtime": "^0.13.2"
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.2.22",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.22.tgz",
"integrity": "sha512-QmEuZsipX5/cR9JOg0fsTN4Yr/9lieYWM8AQpmRa0eIfeOcl/HLYoEa366BCGRSrgNJEexuvOgbq9jnJ22IY5g=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.2.22",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.22.tgz",
"integrity": "sha512-Q941E4x8UfnMH3308n0qrgoja+GoqyiV846JTLoCcCWAKokLKrixCkq6RDBs8r+TtAWaLUrBpI+JFxQNX/WNPQ==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.22"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.10.2.tgz",
"integrity": "sha512-9Os/GRUcy+iVaznlg8GKcPSQFpIQpAg14jF0DWsMdnpJfIftlvfaQCWniR/ex9FoOpSEOrlXqmUCFL+JGeciuA==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.22"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz",
"integrity": "sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g==",
"requires": {
"humps": "^2.0.1",
"prop-types": "^15.5.10"
}
},
"@types/anymatch": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
@ -152,6 +182,12 @@
"@types/node": "*"
}
},
"@types/lodash": {
"version": "4.14.138",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.138.tgz",
"integrity": "sha512-A4uJgHz4hakwNBdHNPdxOTkYmXNgmUAKLbXZ7PKGslgeV0Mb8P3BlbYfPovExek1qnod4pDfRbxuzcVs3dlFLg==",
"dev": true
},
"@types/mime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
@ -243,6 +279,29 @@
"@types/react-router": "*"
}
},
"@types/redux-logger": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.7.tgz",
"integrity": "sha512-oV9qiCuowhVR/ehqUobWWkXJjohontbDGLV88Be/7T4bqMQ3kjXwkFNL7doIIqlbg3X2PC5WPziZ8/j/QHNQ4A==",
"dev": true,
"requires": {
"redux": "^3.6.0"
},
"dependencies": {
"redux": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
"integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==",
"dev": true,
"requires": {
"lodash": "^4.2.1",
"lodash-es": "^4.2.1",
"loose-envify": "^1.1.0",
"symbol-observable": "^1.0.3"
}
}
}
},
"@types/relateurl": {
"version": "0.2.28",
"resolved": "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz",
@ -1854,6 +1913,11 @@
"integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
"dev": true
},
"deep-diff": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-0.3.8.tgz",
"integrity": "sha1-wB3mPvsO7JeYgB1Ax+Da4ltYLIQ="
},
"deep-equal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.0.tgz",
@ -4053,6 +4117,11 @@
"integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
"dev": true
},
"humps": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz",
"integrity": "sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -4592,7 +4661,12 @@
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
},
"lodash-es": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz",
"integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==",
"dev": true
},
"loglevel": {
@ -6337,6 +6411,19 @@
"symbol-observable": "^1.2.0"
}
},
"redux-logger": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/redux-logger/-/redux-logger-3.0.6.tgz",
"integrity": "sha1-91VZZvMJjzyIYExEnPC69XeCdL8=",
"requires": {
"deep-diff": "^0.3.5"
}
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
},
"regenerator-runtime": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
@ -6478,6 +6565,11 @@
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
"dev": true
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
},
"resolve": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz",

11
package.json

@ -15,11 +15,13 @@
},
"devDependencies": {
"@types/html-webpack-plugin": "^3.2.1",
"@types/lodash": "^4.14.138",
"@types/mini-css-extract-plugin": "^0.8.0",
"@types/react": "^16.9.2",
"@types/react-dom": "^16.9.0",
"@types/react-redux": "^7.1.2",
"@types/react-router-dom": "^4.3.5",
"@types/redux-logger": "^3.0.7",
"@types/webpack": "^4.39.1",
"@types/webpack-dev-server": "^3.1.7",
"bulma": "^0.7.5",
@ -38,10 +40,17 @@
"webpack-dev-server": "^3.8.0"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.22",
"@fortawesome/free-solid-svg-icons": "^5.10.2",
"@fortawesome/react-fontawesome": "^0.1.4",
"lodash": "^4.17.15",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-redux": "^7.1.1",
"react-router-dom": "^5.0.1",
"redux": "^4.0.4"
"redux": "^4.0.4",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0"
}
}

28
src/actions/authentication.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,
}

41
src/actions/entities.ts

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

80
src/actions/forms.ts

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

46
src/actions/groups.ts

@ -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
src/actions/index.ts

17
src/actions/menu.ts

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

36
src/actions/notifications.ts

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

38
src/actions/requests.ts

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

60
src/api/errors.ts

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

136
src/api/fetch.ts

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

20
src/api/groups.ts

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

20
src/components/app/app.scss

@ -10,15 +10,31 @@ $primary: $primary-color;
@import "../../../node_modules/bulma/sass/base/_all.sass";
@import "../../../node_modules/bulma/sass/grid/columns.sass";
$main-menu-padding: 1rem;
$main-column-padding: $main-menu-padding;
div#main-menu {
background-color: $primary-color;
bottom: 0;
display: flex;
flex-direction: column;
left: 0;
padding: 20px;
position: absolute;
top: 0;
}
div#header, div#navigation {
padding: $main-menu-padding;
}
div#main-column {
padding: 20px;
padding: $main-column-padding;
}
div#navigation {
flex-grow: 1;
}
footer {
padding: $main-menu-padding;
}

32
src/components/app/app.tsx

@ -1,6 +1,7 @@
import React, { FC } from 'react'
import { HashRouter as Router, Route, Link } from 'react-router-dom'
import UserInfo from '../user-info'
import Home from '../pages/home'
import './app.scss'
@ -18,15 +19,28 @@ const App: FC<Props> = ({ menuCollapsed, fetching }) => {
<Router>
<div>
<div id="main-menu" style={{ width: mainMenuWidth }}>
<h1 className="is-size-2">
<Link className="has-text-white" to="/">flxr</Link>
</h1>
<hr className="has-background-grey-lighter" />
<p>
<Link className="has-text-white" to="/">Timeline</Link>
</p>
<div id="header">
<Link className="has-text-white is-size-2" to="/">flxr</Link>
<hr className="has-background-grey-lighter" />
</div>
<div id="navigation">
<p>
<Link className="has-text-white" to="/">Timeline</Link>
</p>
</div>
<UserInfo />
<footer>
<div className="content has-text-centered has-text-white is-size-7">
<Link className="has-text-white is-inline-block" to="/">Home</Link>
&nbsp;&nbsp;&#9900;&nbsp;&nbsp;
<Link className="has-text-white is-inline-block" to="/">About</Link>
<p>&copy; 2019 Flexor.cc</p>
</div>
</footer>
</div>
<div id="main-column" style={{ marginLeft: mainColumnLeftMargin }}>

26
src/components/notification-container/index.ts

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

6
src/components/notification-container/notification-container.scss

@ -0,0 +1,6 @@
div#notification-container {
bottom: 0;
position: absolute;
right: 0;
width: 25%;
}

33
src/components/notification-container/notification-container.tsx

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

34
src/components/notification/index.tsx

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

20
src/components/pages/directory/directory.tsx

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

21
src/components/pages/directory/index.ts

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

13
src/components/pages/login/index.tsx

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

2
src/components/pages/login/login.scss

@ -0,0 +1,2 @@
@import "../../../../node_modules/bulma/sass/utilities/_all.sass";
@import "../../../../node_modules/bulma/sass/form/_all.sass";

21
src/components/pages/register/index.ts

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

20
src/components/pages/register/register.tsx

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

14
src/components/user-info/index.ts

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

17
src/components/user-info/user-info.scss

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

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

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

21
src/config.ts

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

28
src/reducers/authentication.ts

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

29
src/reducers/entities.ts

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

74
src/reducers/forms.ts

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

18
src/reducers/index.ts

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

22
src/reducers/menu.ts

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

28
src/reducers/notifications.ts

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

39
src/reducers/requests.ts

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

12
src/selectors/index.ts

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

24
src/store/index.ts

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

25
src/types/entities.ts

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

75
src/types/index.ts

@ -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
}
Loading…
Cancel
Save