diff --git a/package-lock.json b/package-lock.json index cccf6c4..9ef52a6 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index 52aeeb5..ba01fc4 100644 --- a/package.json +++ b/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" } } diff --git a/src/actions/authentication.ts b/src/actions/authentication.ts new file mode 100644 index 0000000..5f12a3d --- /dev/null +++ b/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, +} diff --git a/src/actions/entities.ts b/src/actions/entities.ts new file mode 100644 index 0000000..be7bfa2 --- /dev/null +++ b/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, +} diff --git a/src/actions/forms.ts b/src/actions/forms.ts new file mode 100644 index 0000000..ce5f1d1 --- /dev/null +++ b/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, +} diff --git a/src/actions/groups.ts b/src/actions/groups.ts new file mode 100644 index 0000000..b44481d --- /dev/null +++ b/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, AppState, void, AnyAction> => { + return async (dispatch: ThunkDispatch) => { + 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, +} diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/actions/menu.ts b/src/actions/menu.ts new file mode 100644 index 0000000..0eb727a --- /dev/null +++ b/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, +} diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts new file mode 100644 index 0000000..27e3acb --- /dev/null +++ b/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, +} diff --git a/src/actions/requests.ts b/src/actions/requests.ts new file mode 100644 index 0000000..9d0450c --- /dev/null +++ b/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, +} diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 0000000..95f448a --- /dev/null +++ b/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 + } +} diff --git a/src/api/fetch.ts b/src/api/fetch.ts new file mode 100644 index 0000000..ac5f9b1 --- /dev/null +++ b/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 = (options: FetchOptions) => Promise + +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) => { + 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, +} diff --git a/src/api/groups.ts b/src/api/groups.ts new file mode 100644 index 0000000..761dc49 --- /dev/null +++ b/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({ + path: `/api/groups?${querystring}` + }) +} diff --git a/src/components/app/app.scss b/src/components/app/app.scss index da7e1ef..62a38c2 100644 --- a/src/components/app/app.scss +++ b/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; } diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index b9242e1..195b605 100644 --- a/src/components/app/app.tsx +++ b/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 = ({ menuCollapsed, fetching }) => {
diff --git a/src/components/notification-container/index.ts b/src/components/notification-container/index.ts new file mode 100644 index 0000000..7f95ec1 --- /dev/null +++ b/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) diff --git a/src/components/notification-container/notification-container.scss b/src/components/notification-container/notification-container.scss new file mode 100644 index 0000000..ec953b6 --- /dev/null +++ b/src/components/notification-container/notification-container.scss @@ -0,0 +1,6 @@ +div#notification-container { + bottom: 0; + position: absolute; + right: 0; + width: 25%; +} diff --git a/src/components/notification-container/notification-container.tsx b/src/components/notification-container/notification-container.tsx new file mode 100644 index 0000000..d9bbdbb --- /dev/null +++ b/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 = ({ notifications, setAuto, dismiss }) => { + return ( +
+ {notifications.map(notification => { + return ( + + {notification.content} + + ) + })} +
+ ) +} + +export default NotificationContainer diff --git a/src/components/notification/index.tsx b/src/components/notification/index.tsx new file mode 100644 index 0000000..246a9c1 --- /dev/null +++ b/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 = ({ id, type, auto, setAuto, dismiss, children }) => { + const classnames = [ + 'notification', + getClassName(type), + ].join(' ') + + return ( +
setAuto(id)}> + {!auto && } + {children} +
+ ) +} + +export default Notification diff --git a/src/components/pages/directory/directory.tsx b/src/components/pages/directory/directory.tsx new file mode 100644 index 0000000..1d21bec --- /dev/null +++ b/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 = ({ groups, fetchGroups }) => { + return ( +
+

Communities

+ + {groups.length === 0 &&

No Communities

} +
+ ) +} + +export default Directory diff --git a/src/components/pages/directory/index.ts b/src/components/pages/directory/index.ts new file mode 100644 index 0000000..bdd609d --- /dev/null +++ b/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) => ({ + fetchGroups: () => { + dispatch(fetchGroups()) + } +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Directory) diff --git a/src/components/pages/login/index.tsx b/src/components/pages/login/index.tsx new file mode 100644 index 0000000..fc35a4f --- /dev/null +++ b/src/components/pages/login/index.tsx @@ -0,0 +1,13 @@ +import React, { FC } from 'react' + +import './login.scss' + +const Login: FC = () => { + return ( +
+

Login

+
+ ) +} + +export default Login diff --git a/src/components/pages/login/login.scss b/src/components/pages/login/login.scss new file mode 100644 index 0000000..1c392e9 --- /dev/null +++ b/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"; diff --git a/src/components/pages/register/index.ts b/src/components/pages/register/index.ts new file mode 100644 index 0000000..d4aac6e --- /dev/null +++ b/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) => ({ + fetchGroups: () => { + dispatch(fetchGroups()) + } +}) + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Register) diff --git a/src/components/pages/register/register.tsx b/src/components/pages/register/register.tsx new file mode 100644 index 0000000..381ed42 --- /dev/null +++ b/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 = ({ groups, fetchGroups }) => { + return ( +
+

Communities

+ + {groups.length === 0 &&

No Groups

} +
+ ) +} + +export default Register diff --git a/src/components/user-info/index.ts b/src/components/user-info/index.ts new file mode 100644 index 0000000..2257d6e --- /dev/null +++ b/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) diff --git a/src/components/user-info/user-info.scss b/src/components/user-info/user-info.scss new file mode 100644 index 0000000..0dcc145 --- /dev/null +++ b/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; +} diff --git a/src/components/user-info/user-info.tsx b/src/components/user-info/user-info.tsx new file mode 100644 index 0000000..e680b25 --- /dev/null +++ b/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 = ({ authenticated, user }) => { + const getAvatar = () => { + if (authenticated && user && user.imageUrl) { + return + } + + return
+ } + + return ( +
+
+

+ {getAvatar()} +

+
+ +
+
+

+ Log In +
+ Sign Up +

+
+
+
+ ) +} + +export default UserInfo diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7ab611c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,21 @@ +interface Config { + apiUrl: string +} + +declare global { + interface Window { + flexorConfig?: Config + } +} + +export default async function getConfig(): Promise { + 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 +} diff --git a/src/reducers/authentication.ts b/src/reducers/authentication.ts new file mode 100644 index 0000000..6f563af --- /dev/null +++ b/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 = (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 diff --git a/src/reducers/entities.ts b/src/reducers/entities.ts new file mode 100644 index 0000000..6778768 --- /dev/null +++ b/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 = (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 diff --git a/src/reducers/forms.ts b/src/reducers/forms.ts new file mode 100644 index 0000000..1a4f19e --- /dev/null +++ b/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 = (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 diff --git a/src/reducers/index.ts b/src/reducers/index.ts deleted file mode 100644 index 78d0f2b..0000000 --- a/src/reducers/index.ts +++ /dev/null @@ -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 = (state = initialState, action) => { - switch (action.type) { - default: - return state - } -} - -export default reducer diff --git a/src/reducers/menu.ts b/src/reducers/menu.ts new file mode 100644 index 0000000..c82de45 --- /dev/null +++ b/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 = (state = initialState, action) => { + switch (action.type) { + case 'MENU_SET_COLLAPSED': + return { + ...state, + collapsed: action.payload, + } + default: + return state + } +} + +export default reducer diff --git a/src/reducers/notifications.ts b/src/reducers/notifications.ts new file mode 100644 index 0000000..26af1d5 --- /dev/null +++ b/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 = (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 diff --git a/src/reducers/requests.ts b/src/reducers/requests.ts new file mode 100644 index 0000000..6222106 --- /dev/null +++ b/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 = (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 diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 4817902..7b1433e 100644 --- a/src/selectors/index.ts +++ b/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) +} diff --git a/src/store/index.ts b/src/store/index.ts index 401ccac..2edb0f9 100644 --- a/src/store/index.ts +++ b/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({ + authentication, + entities, + menu, + notifications, + requests, + }), + applyMiddleware(logger, thunk) +) export default store diff --git a/src/types/entities.ts b/src/types/entities.ts new file mode 100644 index 0000000..fc35b62 --- /dev/null +++ b/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 +} diff --git a/src/types/index.ts b/src/types/index.ts index f2cd0b2..a509200 100644 --- a/src/types/index.ts +++ b/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 }