├── tests ├── setup.ts ├── .env.example ├── .env.test.example └── example.test.ts ├── .eslintignore ├── public ├── robots.txt ├── favicon.ico ├── sitemap.xml └── template.html ├── .prettierignore ├── .env.example ├── src ├── ui │ ├── landing │ │ ├── assets │ │ │ ├── mindorks-blog.jpg │ │ │ ├── afteracademy-blog.jpg │ │ │ ├── mindorks-youtube.jpg │ │ │ ├── mindorks-opensource.jpg │ │ │ ├── afteracademy-youtube.jpg │ │ │ ├── mindorks-medium-blog.jpg │ │ │ └── mindorks-logo.svg │ │ ├── style.ts │ │ └── index.tsx │ ├── bloglist │ │ ├── assets │ │ │ └── blog-page-cover.jpg │ │ ├── actions.ts │ │ ├── reducer.ts │ │ ├── style.ts │ │ └── index.tsx │ ├── app │ │ ├── actions.ts │ │ ├── style.ts │ │ ├── reducer.ts │ │ ├── routes.tsx │ │ └── index.tsx │ ├── common │ │ ├── confirmation │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── placeholders │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── markdown │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ ├── firstletter │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ ├── preview │ │ │ ├── style.ts │ │ │ └── index.tsx │ │ └── snackbar │ │ │ ├── style.ts │ │ │ └── index.tsx │ ├── auth │ │ ├── style.ts │ │ ├── index.tsx │ │ ├── actions.ts │ │ ├── reducer.ts │ │ ├── login.tsx │ │ └── singup.tsx │ ├── footer │ │ ├── style.ts │ │ └── index.tsx │ ├── notfound │ │ ├── style.ts │ │ └── index.tsx │ ├── blogpage │ │ ├── style.ts │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── index.tsx │ ├── writer │ │ ├── myblogs │ │ │ ├── style.ts │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── index.tsx │ │ └── writingpad │ │ │ ├── style.ts │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ ├── form.tsx │ │ │ └── index.tsx │ ├── editor │ │ └── blogs │ │ │ ├── style.ts │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── index.tsx │ └── header │ │ ├── style.ts │ │ └── assets │ │ └── afteracademy-logo.svg ├── utils │ ├── importer.ts │ ├── pageUtils.ts │ ├── appUtils.ts │ ├── creator.ts │ ├── network.ts │ └── reduxMiddlewares.ts ├── server │ ├── server-types.d.ts │ ├── server.prod.ts │ ├── devStoreConfig.ts │ ├── server.dev.ts │ ├── app.ts │ ├── routes.tsx │ ├── template.ts │ └── pageBuilder.tsx ├── reducers.ts ├── index.tsx ├── app-types.d.ts └── theme.ts ├── .templates ├── github_assets │ ├── screenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ ├── 5.png │ │ ├── 6.png │ │ ├── 7.png │ │ └── 8.png │ ├── cover-react-app.png │ ├── og-cover-react-app.png │ └── ui-component-architecture.png ├── component │ ├── assets │ │ └── .gitkeep │ ├── style.ts │ ├── actions.ts │ ├── reducer.ts │ └── index.tsx └── simple_component │ ├── style.ts │ └── index.tsx ├── .prettierrc ├── tools ├── babel-register.js └── importer-loader.js ├── .vscode ├── launch.json ├── settings.json ├── extensions.list └── tasks.json ├── jest.config.js ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── .babelrc ├── tsconfig.json ├── .eslintrc ├── .gitignore ├── package.json └── webpack.config.js /tests/setup.ts: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: './tests/.env.test' }); 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | github_assets/ 3 | coverage/ 4 | build/ 5 | dist/ 6 | public/ -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | coverage/ 3 | public/ 4 | dist/ 5 | node_modules/ 6 | github_assets/ 7 | *.md -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:3000/ 2 | PORT=3001 3 | API_KEY=GCMUDiuY5a7WvyUNt9n3QztToSHzK7Uj 4 | LOGGING=true -------------------------------------------------------------------------------- /tests/.env.example: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:3000/ 2 | PORT=3001 3 | API_KEY=GCMUDiuY5a7WvyUNt9n3QztToSHzK7Uj 4 | LOGGING=true -------------------------------------------------------------------------------- /tests/.env.test.example: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:3000/ 2 | PORT=3001 3 | API_KEY=GCMUDiuY5a7WvyUNt9n3QztToSHzK7Uj 4 | LOGGING=true -------------------------------------------------------------------------------- /tests/example.test.ts: -------------------------------------------------------------------------------- 1 | describe('dummy test', () => { 2 | it('Should pass', () => { 3 | expect(true).toBe(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/landing/assets/mindorks-blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/mindorks-blog.jpg -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/1.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/2.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/3.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/4.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/5.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/6.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/7.png -------------------------------------------------------------------------------- /.templates/github_assets/screenshots/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/screenshots/8.png -------------------------------------------------------------------------------- /src/ui/bloglist/assets/blog-page-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/bloglist/assets/blog-page-cover.jpg -------------------------------------------------------------------------------- /src/ui/landing/assets/afteracademy-blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/afteracademy-blog.jpg -------------------------------------------------------------------------------- /src/ui/landing/assets/mindorks-youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/mindorks-youtube.jpg -------------------------------------------------------------------------------- /.templates/github_assets/cover-react-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/cover-react-app.png -------------------------------------------------------------------------------- /src/ui/landing/assets/mindorks-opensource.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/mindorks-opensource.jpg -------------------------------------------------------------------------------- /.templates/github_assets/og-cover-react-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/og-cover-react-app.png -------------------------------------------------------------------------------- /src/ui/landing/assets/afteracademy-youtube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/afteracademy-youtube.jpg -------------------------------------------------------------------------------- /src/ui/landing/assets/mindorks-medium-blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/src/ui/landing/assets/mindorks-medium-blog.jpg -------------------------------------------------------------------------------- /.templates/component/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | # Remove this assets directory if the assets is empty 2 | # else remove this .gitkeep if you add any asset to this assets directory -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "semi": true, 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "tabWidth": 2 8 | } 9 | -------------------------------------------------------------------------------- /.templates/github_assets/ui-component-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janishar/react-app-architecture/HEAD/.templates/github_assets/ui-component-architecture.png -------------------------------------------------------------------------------- /tools/babel-register.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const register = require('@babel/register').default; 3 | 4 | register({ extensions: ['.ts', '.tsx', '.js', '.jsx'] }); 5 | -------------------------------------------------------------------------------- /src/utils/importer.ts: -------------------------------------------------------------------------------- 1 | export default function importer(path: string): string { 2 | const parts = path.split('/'); 3 | const last = parts.pop() || parts.pop(); // handle potential trailing slash 4 | return `/assets/${last}`; 5 | } 6 | -------------------------------------------------------------------------------- /.templates/component/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({}: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | })); 8 | 9 | export default useStyles; 10 | -------------------------------------------------------------------------------- /.templates/simple_component/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({}: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | })); 8 | 9 | export default useStyles; 10 | -------------------------------------------------------------------------------- /src/ui/app/actions.ts: -------------------------------------------------------------------------------- 1 | import { actionCreator } from '@utils/creator'; 2 | 3 | export const clearPageTitle = actionCreator('CLEAR_PAGE_TITLE'); 4 | 5 | export const setPageTitle = actionCreator('SET_PAGE_TITLE'); 6 | 7 | clearPageTitle.action(); 8 | -------------------------------------------------------------------------------- /.templates/simple_component/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import useStyles from './style'; 3 | 4 | export default function Component(): ReactElement { 5 | const classes = useStyles(); 6 | return
; 7 | } 8 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://example.afteracademy.com 5 | daily 6 | 0.8 7 | 8 | -------------------------------------------------------------------------------- /src/ui/common/confirmation/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ palette }: Theme) => ({ 4 | paper: { 5 | background: palette.secondary.light, 6 | }, 7 | })); 8 | 9 | export default useStyles; 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "chrome", 6 | "request": "launch", 7 | "name": "Chrome: Attach Debugger", 8 | "url": "http://localhost:3001", 9 | "webRoot": "${workspaceFolder}/src" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/common/placeholders/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing }: Theme) => ({ 4 | box: { 5 | padding: spacing(2), 6 | }, 7 | author: { 8 | marginTop: spacing(3), 9 | }, 10 | })); 11 | 12 | export default useStyles; 13 | -------------------------------------------------------------------------------- /src/server/server-types.d.ts: -------------------------------------------------------------------------------- 1 | import { Cookies } from 'react-cookie'; 2 | import { Request } from 'express'; 3 | 4 | declare interface PublicRequest extends Request { 5 | universalCookies: Cookies; 6 | } 7 | 8 | declare global { 9 | namespace NodeJS { 10 | interface Global { 11 | navigator: any; 12 | htmlTemplate: string; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/tests'], 5 | setupFiles: ['/tests/setup.ts'], 6 | collectCoverageFrom: ['/src/**/*.ts', '!**/node_modules/**'], 7 | moduleNameMapper: { 8 | '@ui/(.*)': '/src/ui/$1', 9 | '@utils/(.*)': '/src/utils/$1', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/ui/app/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({}: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | maxWidth: '1444px', 7 | margin: '0 auto', 8 | float: 'none', 9 | }, 10 | content: { 11 | marginTop: 60, 12 | minHeight: '62vh', 13 | }, 14 | })); 15 | 16 | export default useStyles; 17 | -------------------------------------------------------------------------------- /src/ui/auth/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ palette }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | form: { 8 | display: 'flex', 9 | flexWrap: 'wrap', 10 | }, 11 | paper: { 12 | background: palette.secondary.light, 13 | }, 14 | })); 15 | 16 | export default useStyles; 17 | -------------------------------------------------------------------------------- /src/ui/common/markdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import useStyles from './style'; 3 | import marked from 'marked'; 4 | 5 | export default function Markdown({ text }: { text: string }): ReactElement { 6 | const classes = useStyles(); 7 | return ( 8 |
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /.templates/component/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, StateFetcher } from 'app-types'; 2 | import { actionCreator } from '@utils/creator'; 3 | 4 | export const sendExample = actionCreator('EXAMPLE_ACTION'); 5 | 6 | export const exampleAsyncDispatch = (message: string): AsyncAction => async ( 7 | dispatch: Dispatch, 8 | getState: StateFetcher, 9 | ) => { 10 | dispatch(sendExample.action(message)); 11 | }; 12 | -------------------------------------------------------------------------------- /src/ui/common/firstletter/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ palette, spacing }: Theme) => ({ 4 | root: { 5 | display: 'flex', 6 | '& > *': { 7 | margin: spacing(1), 8 | }, 9 | }, 10 | primary: { 11 | color: palette.primary.contrastText, 12 | backgroundColor: palette.primary.main, 13 | }, 14 | })); 15 | 16 | export default useStyles; 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": true, 3 | "typescript.preferences.importModuleSpecifier": "auto", 4 | "typescript.tsc.autoDetect": "off", 5 | "grunt.autoDetect": "off", 6 | "jake.autoDetect": "off", 7 | "gulp.autoDetect": "off", 8 | "npm.autoDetect": "on", 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | "editor.insertSpaces": true, 11 | "editor.detectIndentation": false, 12 | "editor.tabSize": 2 13 | } 14 | -------------------------------------------------------------------------------- /src/ui/common/firstletter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import useStyles from './style'; 3 | import { Avatar } from '@material-ui/core'; 4 | 5 | export default function FirstLetter({ text }: { text: string }): ReactElement { 6 | const classes = useStyles(); 7 | return ( 8 |
9 | {text && {text.toUpperCase()[0]}} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/footer/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | grid: { 8 | padding: spacing(4), 9 | }, 10 | listItem: { 11 | marginTop: 0, 12 | marginBottom: 0, 13 | paddingTop: 0, 14 | paddingBottom: 0, 15 | }, 16 | icon: { 17 | marginRight: spacing(1), 18 | }, 19 | })); 20 | 21 | export default useStyles; 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Add any directories, files, or patterns you don't want to be tracked by version control 2 | .idea 3 | 4 | # ignore vs code project config files 5 | .vscode 6 | .vs 7 | .templates 8 | 9 | # ignore logs 10 | logs 11 | *.log 12 | 13 | # ignore 3rd party lib 14 | node_modules 15 | 16 | # github repository readme assets 17 | github_assets 18 | 19 | # Ignore built files 20 | build 21 | dist 22 | 23 | # ignore test converage 24 | coverage 25 | 26 | # git 27 | .gitignore 28 | .git 29 | 30 | .DS_Store -------------------------------------------------------------------------------- /src/utils/pageUtils.ts: -------------------------------------------------------------------------------- 1 | export const setPageTitle = (title: string) => { 2 | if (title && document) document.title = title; 3 | }; 4 | 5 | export const scrollPageToTop = () => { 6 | if (window) window.scrollTo(0, 0); 7 | }; 8 | 9 | export const removeAppLoader = () => { 10 | if (document) { 11 | const appLoader = document.getElementById('appLoader'); 12 | if (appLoader) 13 | setTimeout(() => { 14 | if (appLoader?.parentNode) appLoader.parentNode.removeChild(appLoader); 15 | }, 500); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/ui/notfound/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, palette }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | padding: spacing(2), 7 | height: '62vh', 8 | }, 9 | icon: { 10 | display: 'flex', 11 | margin: 'auto', 12 | height: 80, 13 | width: 80, 14 | color: palette.grey[600], 15 | }, 16 | message: { 17 | marginTop: spacing(1), 18 | color: palette.grey[600], 19 | }, 20 | })); 21 | 22 | export default useStyles; 23 | -------------------------------------------------------------------------------- /src/server/server.prod.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import routes from './routes'; 3 | import { Request, Response, NextFunction } from 'express'; 4 | 5 | app.use(routes); 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 9 | res.status(500).send('INTERNAL SERVER ERROR'); 10 | }); 11 | 12 | app 13 | .listen(process.env.PORT || 3001, () => { 14 | console.log(`🚧 server listening on port : ${process.env.PORT || 3001}`); 15 | }) 16 | .on('error', (e) => console.log(e)); 17 | -------------------------------------------------------------------------------- /.templates/component/reducer.ts: -------------------------------------------------------------------------------- 1 | import { sendExample } from './actions'; 2 | import { Action } from 'app-types'; 3 | 4 | export type State = { 5 | exampleVariable: string | null; 6 | }; 7 | 8 | export const defaultState: State = { 9 | exampleVariable: null, 10 | }; 11 | 12 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 13 | switch (type) { 14 | case sendExample.type: 15 | return { 16 | ...state, 17 | exampleVariable: payload, 18 | }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | export default reducer; 25 | -------------------------------------------------------------------------------- /src/ui/blogpage/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, palette, breakpoints }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | background: palette.background.default, 7 | paddingBottom: spacing(4), 8 | }, 9 | blogContent: { 10 | padding: spacing(6), 11 | minBlockSize: '55vh', 12 | background: palette.background.default, 13 | [breakpoints.down('md')]: { 14 | padding: spacing(2), 15 | }, 16 | }, 17 | author: { 18 | background: palette.background.default, 19 | }, 20 | })); 21 | 22 | export default useStyles; 23 | -------------------------------------------------------------------------------- /src/ui/common/preview/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, palette, breakpoints }: Theme) => ({ 4 | title: { 5 | marginLeft: spacing(2), 6 | flex: 1, 7 | }, 8 | blogContent: { 9 | marginTop: spacing(3), 10 | padding: spacing(6), 11 | background: palette.background.default, 12 | paddingBlockStart: spacing(10), 13 | [breakpoints.down('md')]: { 14 | padding: spacing(2), 15 | marginTop: 0, 16 | }, 17 | }, 18 | paper: { 19 | background: palette.background.default, 20 | }, 21 | })); 22 | 23 | export default useStyles; 24 | -------------------------------------------------------------------------------- /.templates/component/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { sendExample } from './actions'; 5 | import { useStateSelector } from '@core/reducers'; 6 | 7 | export default function Component({ exampleProp }: { exampleProp: any }): ReactElement { 8 | const classes = useStyles(); 9 | const appState = useStateSelector((state) => state.appState); 10 | const dispatch = useDispatch(); 11 | 12 | useEffect(() => { 13 | dispatch(sendExample.action('Example Message')); 14 | }, [dispatch]); 15 | 16 | return
; 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Here we are getting our node as Base image 2 | FROM node:13 3 | 4 | # create user in the docker image 5 | USER node 6 | 7 | # Creating a new directory for app files and setting path in the container 8 | RUN mkdir -p /home/node/react && chown -R node:node /home/node/react 9 | 10 | # setting working directory in the container 11 | WORKDIR /home/node/react 12 | 13 | # grant permission of node project directory to node user 14 | COPY --chown=node:node . . 15 | 16 | # installing the dependencies into the container 17 | RUN npm install 18 | 19 | # container exposed network port number 20 | EXPOSE 3000 21 | 22 | # command to run within the container 23 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /src/ui/app/reducer.ts: -------------------------------------------------------------------------------- 1 | import { clearPageTitle, setPageTitle } from './actions'; 2 | import { Action } from 'app-types'; 3 | 4 | const PAGE_DEFAULT_TITLE = 'AfterAcademy | OpenSource Project'; 5 | 6 | export type State = { 7 | currentPageTitle: string | null; 8 | }; 9 | 10 | export const defaultState: State = { 11 | currentPageTitle: null, 12 | }; 13 | 14 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 15 | switch (type) { 16 | case clearPageTitle.type: 17 | return { ...defaultState }; 18 | case setPageTitle.type: 19 | return { 20 | ...state, 21 | currentPageTitle: typeof payload === 'string' ? payload : PAGE_DEFAULT_TITLE, 22 | }; 23 | default: 24 | return state; 25 | } 26 | }; 27 | 28 | export default reducer; 29 | -------------------------------------------------------------------------------- /src/ui/common/snackbar/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing }: Theme) => ({ 4 | success: { 5 | backgroundColor: '#C9ECD4', 6 | color: 'black', 7 | }, 8 | error: { 9 | backgroundColor: '#FF9494', 10 | color: 'black', 11 | }, 12 | info: { 13 | backgroundColor: '#fff0f0', 14 | color: 'black', 15 | }, 16 | warning: { 17 | backgroundColor: '#FFC48C', 18 | color: 'black', 19 | }, 20 | icon: { 21 | fontSize: 20, 22 | }, 23 | iconVariant: { 24 | opacity: 0.9, 25 | marginRight: spacing(1), 26 | }, 27 | message: { 28 | display: 'flex', 29 | alignItems: 'center', 30 | }, 31 | contentMargin: { 32 | margin: spacing(1), 33 | }, 34 | })); 35 | 36 | export default useStyles; 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | react: 5 | # This defines the configuration options, including the context and dockerfile, 6 | # that will be applied when Compose builds the application image. 7 | build: 8 | # This defines the build context for the image build — in this case, the current project directory. 9 | context: . 10 | # This specifies the Dockerfile in your current project directory as the file 11 | dockerfile: Dockerfile 12 | image: react 13 | container_name: react 14 | # This defines the restart policy. The default is no, 15 | # but we have set the container to restart unless it is stopped. 16 | restart: unless-stopped 17 | env_file: .env 18 | ports: 19 | # This maps port from .env on the host to same port number on the container. 20 | - '$PORT:$PORT' 21 | -------------------------------------------------------------------------------- /src/server/devStoreConfig.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Store, applyMiddleware } from 'redux'; 2 | import rootReducer, { RootState } from '../reducers'; 3 | import thunk from 'redux-thunk'; 4 | import { logger, crashReporter } from '../utils/reduxMiddlewares'; 5 | 6 | const devStoreConfig = (preloadedState: Partial): Store => { 7 | const store = createStore( 8 | rootReducer, 9 | preloadedState, 10 | applyMiddleware(thunk, logger, crashReporter), 11 | ); 12 | 13 | // @ts-ignore 14 | if (module.hot) { 15 | // Enable Webpack hot module replacement for reducers 16 | // @ts-ignore 17 | module.hot.accept('../reducers', () => { 18 | const nextRootReducer = require('../reducers'); 19 | store.replaceReducer(nextRootReducer); 20 | }); 21 | } 22 | 23 | return store; 24 | }; 25 | 26 | export default devStoreConfig; 27 | -------------------------------------------------------------------------------- /src/ui/notfound/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import useStyles from './style'; 3 | import { DirectionsBoatRounded } from '@material-ui/icons'; 4 | import { Box, Typography } from '@material-ui/core'; 5 | 6 | export default function Component({ message = 'Not Found' }: { message?: string }): ReactElement { 7 | const classes = useStyles(); 8 | return ( 9 | 17 |
18 | 19 | 20 | {message} 21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/server/server.dev.ts: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import webpackDevMiddleware from 'webpack-dev-middleware'; 3 | import webpackHotMiddleware from 'webpack-hot-middleware'; 4 | import app from './app'; 5 | import routes from './routes'; 6 | import webpackConfig from '../../webpack.config'; 7 | 8 | const serverOptions = { 9 | quiet: false, 10 | noInfo: false, 11 | hot: true, 12 | inline: true, 13 | lazy: false, 14 | publicPath: webpackConfig.output.publicPath, 15 | stats: { colors: true }, 16 | }; 17 | // @ts-ignore 18 | const compiler = webpack(webpackConfig); 19 | app.use(webpackDevMiddleware(compiler, serverOptions)); 20 | app.use(webpackHotMiddleware(compiler)); 21 | 22 | app.use(routes); 23 | 24 | app 25 | .listen(process.env.PORT || 3001, () => { 26 | console.log(`🚧 server listening on port : ${process.env.PORT || 3001}`); 27 | }) 28 | .on('error', (e) => console.log(e)); 29 | -------------------------------------------------------------------------------- /src/ui/bloglist/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, Blog } from 'app-types'; 2 | import { networkActionsCreator, actionCreator } from '@utils/creator'; 3 | import { publicRequest } from '@utils/network'; 4 | 5 | export const removeMessage = actionCreator('CLEAR_BLOGS_MESSAGE'); 6 | export const blogsLatestActions = networkActionsCreator>('LATEST_BLOGS'); 7 | 8 | /** 9 | * @todo: implement pagination based ui 10 | */ 11 | export const fetchLatestBlogs = (): AsyncAction => async (dispatch: Dispatch) => { 12 | try { 13 | dispatch(blogsLatestActions.requesting.action()); 14 | const response = await publicRequest>({ 15 | url: 'blogs/latest', 16 | method: 'GET', 17 | params: { pageNumber: 1, pageItemCount: 1000 }, 18 | }); 19 | dispatch(blogsLatestActions.success.action(response)); 20 | } catch (e) { 21 | dispatch(blogsLatestActions.failure.action(e)); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/ui/blogpage/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, Blog } from 'app-types'; 2 | import { actionCreator, networkActionsCreator } from '@utils/creator'; 3 | import { publicRequest } from '@utils/network'; 4 | 5 | export const removeMessage = actionCreator('CLEAR_BLOG_PAGE_MESSAGE'); 6 | export const clearPage = actionCreator('CLEAR_BLOG_PAGE'); 7 | export const blogActions = networkActionsCreator('BLOG_PAGE'); 8 | 9 | export const fetchBlogByEndpoint = (endpoint: string): AsyncAction => async ( 10 | dispatch: Dispatch, 11 | ) => { 12 | try { 13 | dispatch(blogActions.requesting.action()); 14 | const response = await publicRequest({ 15 | url: 'blog/url', 16 | method: 'GET', 17 | params: { 18 | endpoint: endpoint, 19 | }, 20 | }); 21 | dispatch(blogActions.success.action(response)); 22 | } catch (e) { 23 | dispatch(blogActions.failure.action(e)); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/ui/writer/myblogs/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | paddingBottom: spacing(1), 7 | }, 8 | pannel: { 9 | paddingTop: spacing(4), 10 | }, 11 | tab: { 12 | minHeight: 65, 13 | textTransform: 'none', 14 | fontSize: 16, 15 | }, 16 | button1: { 17 | marginRight: spacing(1), 18 | marginTop: spacing(1), 19 | marginBottom: spacing(1), 20 | }, 21 | button2: { 22 | marginLeft: spacing(1), 23 | marginTop: spacing(1), 24 | marginBottom: spacing(1), 25 | }, 26 | list: { 27 | width: '100%', 28 | }, 29 | chip: { 30 | marginRight: spacing(1), 31 | marginBottom: spacing(1), 32 | }, 33 | chip2: { 34 | marginTop: spacing(1), 35 | marginRight: spacing(1), 36 | marginBottom: spacing(1), 37 | }, 38 | })); 39 | 40 | export default useStyles; 41 | -------------------------------------------------------------------------------- /src/ui/landing/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ palette, spacing, breakpoints }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | aboutUsSection: { 8 | paddingTop: spacing(6), 9 | paddingBottom: spacing(10), 10 | background: palette.secondary.dark, 11 | }, 12 | sectionHeading: { 13 | marginBottom: spacing(6), 14 | marginTop: spacing(2), 15 | }, 16 | infoCard: { 17 | height: '100%', 18 | [breakpoints.down('sm')]: { 19 | padding: spacing(2), 20 | }, 21 | background: palette.secondary.light, 22 | }, 23 | card: { 24 | height: '100%', 25 | }, 26 | cardAction: { 27 | paddingBottom: spacing(3), 28 | }, 29 | avatar: { 30 | width: 60, 31 | height: 60, 32 | }, 33 | button: { 34 | marginLeft: spacing(2), 35 | }, 36 | resourcesSection: { 37 | paddingTop: spacing(6), 38 | paddingBottom: spacing(10), 39 | }, 40 | })); 41 | 42 | export default useStyles; 43 | -------------------------------------------------------------------------------- /tools/importer-loader.js: -------------------------------------------------------------------------------- 1 | module.exports = function (source) { 2 | if (!this.query.functionName) 3 | throw new Error('Provide the functionName in options for this loader'); 4 | 5 | const regex = new RegExp(`${this.query.functionName}\\((.*)\\)`, 'g'); 6 | 7 | const results = []; 8 | let match = null; 9 | 10 | if (regex.global) { 11 | while ((match = regex.exec(source))) { 12 | results.push(match); 13 | } 14 | } else { 15 | if ((match = regex.exec(source))) { 16 | results.push(match); 17 | } 18 | } 19 | 20 | const entries = results.map((result) => result[1].trim()); 21 | const filtered = entries.filter( 22 | (entry) => 23 | (entry.startsWith('"') && entry.endsWith('"')) || 24 | (entry.startsWith("'") && entry.endsWith("'")) || 25 | (entry.startsWith('`') && entry.endsWith('`')), 26 | ); 27 | const transformed = filtered.map((entry) => `import ${entry};`); 28 | const output = transformed.join('\r\n'); 29 | 30 | return output + '\n' + source; 31 | }; 32 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], 3 | "plugins": [ 4 | "react-hot-loader/babel", 5 | "@babel/proposal-class-properties", 6 | "@babel/proposal-object-rest-spread", 7 | "@babel/plugin-transform-runtime", 8 | [ 9 | "module-resolver", 10 | { 11 | "root": ["./src"], 12 | "alias": { 13 | "@ui": "./src/ui", 14 | "@utils": "./src/utils", 15 | "@core/reducers": "./src/reducers", 16 | "@core/theme": "./src/theme" 17 | } 18 | } 19 | ], 20 | [ 21 | "babel-plugin-import", 22 | { 23 | "libraryName": "@material-ui/core", 24 | "libraryDirectory": "", 25 | "camel2DashComponentName": false 26 | }, 27 | "core" 28 | ], 29 | [ 30 | "babel-plugin-import", 31 | { 32 | "libraryName": "@material-ui/icons", 33 | "libraryDirectory": "", 34 | "camel2DashComponentName": false 35 | }, 36 | "icons" 37 | ] 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/editor/blogs/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | paddingBottom: spacing(1), 7 | }, 8 | pannel: { 9 | paddingTop: spacing(4), 10 | }, 11 | tab: { 12 | minHeight: 65, 13 | textTransform: 'none', 14 | fontSize: 16, 15 | }, 16 | button1: { 17 | marginTop: spacing(1), 18 | marginBottom: spacing(1), 19 | }, 20 | button2: { 21 | marginLeft: spacing(1), 22 | marginTop: spacing(1), 23 | marginBottom: spacing(1), 24 | }, 25 | list: { 26 | width: '100%', 27 | }, 28 | chip: { 29 | marginRight: spacing(1), 30 | marginBottom: spacing(1), 31 | }, 32 | chip2: { 33 | marginTop: spacing(1), 34 | marginRight: spacing(1), 35 | marginBottom: spacing(1), 36 | }, 37 | chip3: { 38 | marginLeft: spacing(1), 39 | marginTop: spacing(1), 40 | }, 41 | chip4: { 42 | marginTop: spacing(1), 43 | cursor: 'pointer', 44 | }, 45 | author: { 46 | marginTop: spacing(1), 47 | }, 48 | })); 49 | 50 | export default useStyles; 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | // Target latest version of ECMAScript. 5 | "target": "esnext", 6 | // Search under node_modules for non-relative imports. 7 | "moduleResolution": "node", 8 | // Process & infer types from .js files. 9 | "allowJs": true, 10 | // Don't emit; allow Babel to transform files. 11 | "noEmit": true, 12 | // Enable strictNullChecks & noImplicitAny. 13 | "strictNullChecks": true, 14 | "noImplicitAny": true, 15 | // Disallow features that require cross-file information for emit. 16 | "isolatedModules": true, 17 | // Import non-ES modules as default imports. 18 | "esModuleInterop": true, 19 | // Enable support for jsx files 20 | "jsx": "react", 21 | // Import .json files 22 | "resolveJsonModule": true, 23 | "baseUrl": ".", 24 | "paths": { 25 | "*": ["node_modules/*", "src/*.d.ts", "src/server/*.d.ts"], 26 | "@ui/*": ["src/ui/*"], 27 | "@utils/*": ["src/utils/*"], 28 | "@core/reducers": ["src/reducers.ts"], 29 | "@core/theme": ["src/theme.ts"] 30 | } 31 | }, 32 | "include": ["src/**/*", ".templates/**/*"] 33 | } 34 | -------------------------------------------------------------------------------- /src/server/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { join } from 'path'; 3 | import cookiesMiddleware from 'universal-cookie-express'; 4 | import favicon from 'serve-favicon'; 5 | import register from 'ignore-styles'; 6 | import { loadTemplateBlocking } from './template'; 7 | 8 | global.htmlTemplate = loadTemplateBlocking(); 9 | 10 | const app = express(); 11 | app.set('port', process.env.PORT || 3001); 12 | app.use('/template.html', (req, res) => res.status(404).send('NOT FOUND')); 13 | app.use(express.static(join(__dirname, '../../dist'), { maxAge: '7d' })); //seven day cache 14 | app.use(express.static(join(__dirname, '../../public'))); 15 | app.use(favicon(join(__dirname, '../../public', 'favicon.ico'))); 16 | app.get('/sitemap', (req, res) => res.sendFile(join(__dirname, '../../public/sitemap.xml'))); 17 | 18 | global.navigator = { userAgent: 'all' }; 19 | 20 | // This is added so that @babel/register don't crash when compiling modular style dependencies 21 | register([ 22 | '.sass', 23 | '.scss', 24 | '.less', 25 | '.css', 26 | '.svg', 27 | '.eot', 28 | '.woff', 29 | '.woff2', 30 | '.ttf', 31 | '.png', 32 | '.jpg', 33 | '.jpeg', 34 | ]); 35 | 36 | app.use(cookiesMiddleware()); 37 | 38 | export default app; 39 | -------------------------------------------------------------------------------- /src/ui/bloglist/reducer.ts: -------------------------------------------------------------------------------- 1 | import { blogsLatestActions, removeMessage } from './actions'; 2 | import { Action, Message, Blog } from 'app-types'; 3 | 4 | export type State = { 5 | data: Array | null; 6 | isFetching: boolean; 7 | message: Message | null; 8 | }; 9 | 10 | export const defaultState: State = { 11 | data: null, 12 | isFetching: false, 13 | message: null, 14 | }; 15 | 16 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 17 | switch (type) { 18 | case removeMessage.type: 19 | return { 20 | ...state, 21 | message: null, 22 | }; 23 | case blogsLatestActions.requesting.type: 24 | return { 25 | ...state, 26 | isFetching: true, 27 | }; 28 | case blogsLatestActions.failure.type: 29 | return { 30 | ...state, 31 | isFetching: false, 32 | message: { 33 | type: 'error', 34 | text: 'No more blogs', 35 | }, 36 | }; 37 | case blogsLatestActions.success.type: 38 | return { 39 | ...state, 40 | isFetching: false, 41 | data: payload.data, 42 | }; 43 | default: 44 | return state; 45 | } 46 | }; 47 | 48 | export default reducer; 49 | -------------------------------------------------------------------------------- /src/ui/common/markdown/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, palette }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | fontSize: 16, 7 | maxWidth: '100%', 8 | '& a': { 9 | color: palette.common.white, 10 | textDecoration: 'underline', 11 | }, 12 | '& pre': { 13 | padding: spacing(2), 14 | background: palette.secondary.dark, 15 | whiteSpace: 'pre-wrap', 16 | wordWrap: 'break-word', 17 | fontSize: 13, 18 | '& code': { 19 | background: palette.secondary.dark, 20 | }, 21 | }, 22 | '& h1': { 23 | fontFamily: 'Roboto Condensed,Roboto,sans-serif', 24 | lineHeight: 1.25, 25 | }, 26 | '& p, ol, ul': { 27 | lineHeight: 1.8, 28 | }, 29 | '& code': { 30 | fontFamily: 'Menlo, Monaco, Consolas, "Courier New", "Courier", monospace;', 31 | display: 'inline', 32 | padding: '1px 5px 3px', 33 | borderRadius: 2, 34 | background: palette.grey[800], 35 | }, 36 | '& img': { 37 | maxWidth: '100%', 38 | }, 39 | '& li': { 40 | paddingBottom: spacing(1), 41 | }, 42 | }, 43 | })); 44 | 45 | export default useStyles; 46 | -------------------------------------------------------------------------------- /src/ui/blogpage/reducer.ts: -------------------------------------------------------------------------------- 1 | import { removeMessage, blogActions, clearPage } from './actions'; 2 | import { Action, Message, Blog } from 'app-types'; 3 | 4 | export type State = { 5 | data: Blog | null; 6 | isFetching: boolean; 7 | message: Message | null; 8 | }; 9 | 10 | export const defaultState: State = { 11 | data: null, 12 | isFetching: false, 13 | message: null, 14 | }; 15 | 16 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 17 | switch (type) { 18 | case clearPage.type: 19 | return { 20 | ...defaultState, 21 | }; 22 | case removeMessage.type: 23 | return { 24 | ...state, 25 | message: null, 26 | }; 27 | case blogActions.requesting.type: 28 | return { 29 | ...state, 30 | isFetching: true, 31 | }; 32 | case blogActions.failure.type: 33 | return { 34 | ...state, 35 | isFetching: false, 36 | message: { 37 | type: 'error', 38 | text: 'Please refresh the page', 39 | }, 40 | }; 41 | case blogActions.success.type: 42 | return { 43 | ...state, 44 | isFetching: false, 45 | data: payload.data, 46 | }; 47 | default: 48 | return state; 49 | } 50 | }; 51 | 52 | export default reducer; 53 | -------------------------------------------------------------------------------- /src/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { TypedUseSelectorHook, useSelector } from 'react-redux'; 3 | import appReducer, { State as AppState } from '@ui/app/reducer'; 4 | import authReducer, { State as AuthState } from '@ui/auth/reducer'; 5 | import blogListReducer, { State as BlogListState } from '@ui/bloglist/reducer'; 6 | import blogReducer, { State as BlogState } from '@ui/blogpage/reducer'; 7 | import writerBlogsReducer, { State as WriterBlogsState } from '@ui/writer/myblogs/reducer'; 8 | import writingPadReducer, { State as WritingPadState } from '@ui/writer/writingpad/reducer'; 9 | import editorBlogsReducer, { State as EditorBlogState } from '@ui/editor/blogs/reducer'; 10 | 11 | export type RootState = { 12 | appState: AppState; 13 | authState: AuthState; 14 | blogListState: BlogListState; 15 | blogState: BlogState; 16 | writerBlogsState: WriterBlogsState; 17 | writingPadState: WritingPadState; 18 | editorBlogState: EditorBlogState; 19 | }; 20 | 21 | export const useStateSelector: TypedUseSelectorHook = useSelector; 22 | 23 | const rootReducer = combineReducers({ 24 | appState: appReducer, 25 | authState: authReducer, 26 | blogListState: blogListReducer, 27 | blogState: blogReducer, 28 | writerBlogsState: writerBlogsReducer, 29 | writingPadState: writingPadReducer, 30 | editorBlogState: editorBlogsReducer, 31 | }); 32 | 33 | export default rootReducer; 34 | -------------------------------------------------------------------------------- /src/utils/appUtils.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from '@core/reducers'; 2 | import { Roles } from '@ui/auth/reducer'; 3 | import { User } from 'app-types'; 4 | 5 | export const validateEmail = (email: string) => { 6 | const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 7 | return re.test(email); 8 | }; 9 | 10 | export const validateToken = (rootState: RootState) => { 11 | const token = rootState?.authState?.data?.tokens?.accessToken; 12 | if (!token) throw new Error('Please Log in'); 13 | return token; 14 | }; 15 | 16 | export const convertToReadableDate = (date: string): string => formatDate(new Date(date)); 17 | 18 | export const formatDate = (date: Date): string => 19 | date.toLocaleDateString('en-US', { day: 'numeric' }) + 20 | ' ' + 21 | date.toLocaleDateString('en-US', { month: 'short' }) + 22 | ' ' + 23 | date.toLocaleDateString('en-US', { year: 'numeric' }); 24 | 25 | export const checkRole = (user: User | null | undefined, role: Roles): boolean => 26 | user?.roles?.find((item) => item?.code === role) !== undefined; 27 | 28 | export const validateUrl = (url: string | null | undefined) => { 29 | if (!url) return false; 30 | if (!url.startsWith('http')) return false; 31 | const re = /^(http[s]?:\/\/){0,1}(www\.){0,1}[a-zA-Z0-9\.\-]+\.[a-zA-Z]{2,5}[\.]{0,1}/; 32 | return re.test(url); 33 | }; 34 | -------------------------------------------------------------------------------- /.vscode/extensions.list: -------------------------------------------------------------------------------- 1 | code --install-extension 74th.Theme-NaturalContrast-With-HC 2 | code --install-extension abusaidm.html-snippets 3 | code --install-extension cssho.vscode-svgviewer 4 | code --install-extension dbaeumer.jshint 5 | code --install-extension dbaeumer.vscode-eslint 6 | code --install-extension DotJoshJohnson.xml 7 | code --install-extension dracula-theme.theme-dracula 8 | code --install-extension dsznajder.es7-react-js-snippets 9 | code --install-extension eamodio.gitlens 10 | code --install-extension ecmel.vscode-html-css 11 | code --install-extension esbenp.prettier-vscode 12 | code --install-extension in4margaret.compareit 13 | code --install-extension k--kato.intellij-idea-keybindings 14 | code --install-extension lonefy.vscode-JS-CSS-HTML-formatter 15 | code --install-extension mgmcdermott.vscode-language-babel 16 | code --install-extension michelemelluso.code-beautifier 17 | code --install-extension mikestead.dotenv 18 | code --install-extension ms-azuretools.vscode-docker 19 | code --install-extension ms-vscode.typescript-javascript-grammar 20 | code --install-extension ms-vscode.vscode-typescript-tslint-plugin 21 | code --install-extension Orta.vscode-jest 22 | code --install-extension skyran.js-jsx-snippets 23 | code --install-extension VisualStudioExptTeam.vscodeintellicode 24 | code --install-extension xabikos.JavaScriptSnippets 25 | code --install-extension Zignd.html-css-class-completion 26 | code --install-extension msjsdiag.debugger-for-chrome -------------------------------------------------------------------------------- /src/ui/header/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ palette, spacing, breakpoints }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | appbar: { 8 | boxShadow: '0px 4px 8px 0px rgba(0, 0, 0, 0.1)', 9 | }, 10 | menuButton: { 11 | marginRight: spacing(2), 12 | }, 13 | brandName: { 14 | flexGrow: 1, 15 | }, 16 | logo: { 17 | margin: 10, 18 | }, 19 | button: { 20 | margin: spacing(1), 21 | textTransform: 'none', 22 | fontSize: 16, 23 | }, 24 | loginButton: { 25 | margin: spacing(1), 26 | paddingLeft: 40, 27 | }, 28 | sectionDesktop: { 29 | display: 'none', 30 | [breakpoints.up('md')]: { 31 | display: 'flex', 32 | }, 33 | }, 34 | sectionMobile: { 35 | display: 'flex', 36 | [breakpoints.up('md')]: { 37 | display: 'none', 38 | }, 39 | }, 40 | drawerItem: { 41 | paddingLeft: spacing(2), 42 | paddingRight: spacing(2), 43 | paddingTop: spacing(1), 44 | paddingBottom: spacing(1), 45 | }, 46 | drawerIcon: { 47 | color: palette.common.white, 48 | }, 49 | drawerCloseButton: { 50 | marginLeft: '45%', 51 | }, 52 | drawerCloseButtonContainer: { 53 | margin: spacing(1), 54 | }, 55 | avatar: { 56 | margin: 5, 57 | width: 40, 58 | height: 40, 59 | }, 60 | menuItem: { 61 | minWidth: 300, 62 | }, 63 | paper: { 64 | background: palette.secondary.light, 65 | }, 66 | })); 67 | 68 | export default useStyles; 69 | -------------------------------------------------------------------------------- /src/ui/app/routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | import { useStateSelector } from '@core/reducers'; 4 | import { Roles } from '@ui/auth/reducer'; 5 | import { checkRole } from '@utils/appUtils'; 6 | import Landing from '@ui/landing'; 7 | import NotFound from '@ui/notfound'; 8 | import BlogList from '@ui/bloglist'; 9 | import BlogPage from '@ui/blogpage'; 10 | import WriterMyBlogs from '@ui/writer/myblogs'; 11 | import WritingPad from '@ui/writer/writingpad'; 12 | import EditorBlogs from '@ui/editor/blogs'; 13 | 14 | export default function Routes(): ReactElement { 15 | const { data } = useStateSelector(({ authState }) => authState); 16 | 17 | const isWriter = checkRole(data?.user, Roles.WRITER); 18 | const isEditor = checkRole(data?.user, Roles.EDITOR); 19 | 20 | return ( 21 | 22 | {/* PUBLIC CONTENTS */} 23 | 24 | 25 | 26 | {} 27 | 28 | {/* PRIVATE CONTENTS */} 29 | {isWriter && } 30 | {isWriter && } 31 | {isEditor && } 32 | 33 | {/*FALLBACK*/} 34 | {} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/bloglist/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, breakpoints }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | cards: { 8 | marginTop: spacing(6), 9 | marginBottom: spacing(8), 10 | }, 11 | cover: { 12 | width: '100%', 13 | height: 300, 14 | padding: 0, 15 | backgroundImage: "url('/assets/blog-page-cover.jpg')", 16 | backgroundSize: 'cover', 17 | backgroundRepeat: 'no-repeat', 18 | backgroundPosition: 'center center', 19 | [breakpoints.down('xs')]: { 20 | height: 200, 21 | }, 22 | }, 23 | coverBox: { 24 | padding: spacing(10), 25 | height: '100%', 26 | background: 'rgba(0,0,0, 0.2)', 27 | [breakpoints.down('xs')]: { 28 | padding: spacing(8), 29 | }, 30 | }, 31 | card: { 32 | height: '100%', 33 | }, 34 | cardContent: { 35 | height: '100%', 36 | }, 37 | cardMedia: { 38 | height: 220, 39 | }, 40 | cardAuthor: { 41 | height: 55, 42 | position: 'absolute', 43 | bottom: 0, 44 | left: 0, 45 | marginBottom: spacing(1), 46 | }, 47 | cardTitle: { 48 | lineHeight: '1.3', 49 | }, 50 | cardDescription: { 51 | marginTop: spacing(1), 52 | marginBottom: 50, 53 | minHeight: 80, 54 | }, 55 | coverTitle: { 56 | [breakpoints.down('xs')]: { 57 | fontSize: 32, 58 | }, 59 | }, 60 | coverSubtitle: { 61 | [breakpoints.down('xs')]: { 62 | fontSize: 22, 63 | }, 64 | }, 65 | })); 66 | 67 | export default useStyles; 68 | -------------------------------------------------------------------------------- /src/ui/auth/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState, Fragment, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { useStateSelector } from '@core/reducers'; 4 | import { removeMessage } from './actions'; 5 | 6 | import Snackbar from '@ui/common/snackbar'; 7 | import LoginDialog from './login'; 8 | import SignupDialog from './singup'; 9 | 10 | export default function AuthDialog({ 11 | open, 12 | onClose, 13 | }: { 14 | open: boolean; 15 | onClose: () => void; 16 | }): ReactElement | null { 17 | const dispatch = useDispatch(); 18 | const { isLoggedIn, message } = useStateSelector(({ authState }) => authState); 19 | 20 | const [signup, setSignup] = useState(false); 21 | 22 | useEffect(() => { 23 | if (isLoggedIn) { 24 | onClose(); 25 | setSignup(false); 26 | } 27 | // eslint-disable-next-line react-hooks/exhaustive-deps 28 | }, [isLoggedIn]); 29 | 30 | if (isLoggedIn) return null; // important to remount the auth with fresh state 31 | return ( 32 | 33 | { 36 | if (!signup) onClose(); 37 | }} 38 | onSignup={() => setSignup(true)} 39 | /> 40 | onClose()} 43 | onLogin={() => setSignup(false)} 44 | /> 45 | {message && ( 46 | dispatch(removeMessage.action())} 50 | /> 51 | )} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/ui/writer/writingpad/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles(({ spacing, palette, typography, breakpoints }: Theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | background: palette.background.paper, 7 | }, 8 | content: { 9 | paddingTop: 0, 10 | }, 11 | pad: { 12 | width: '100% !important', 13 | background: palette.background.default, 14 | ...typography.body2, 15 | color: palette.grey[200], 16 | resize: 'none', 17 | border: 'none', 18 | outline: 'none', 19 | boxShadow: 'none', 20 | fontFamily: 'Menlo, Monaco, Consolas, "Courier New", "Courier", monospace;', 21 | '-webkit-box-shadow': 'none', 22 | '-moz-box-shadow': 'none', 23 | marginTop: spacing(3), 24 | marginBottom: spacing(6), 25 | padding: spacing(6), 26 | paddingBlockStart: spacing(10), 27 | [breakpoints.down('md')]: { 28 | padding: spacing(2), 29 | marginBottom: spacing(2), 30 | marginTop: 0, 31 | }, 32 | }, 33 | speedDial: { 34 | position: 'fixed', 35 | top: 100, 36 | right: spacing(3), 37 | }, 38 | progress: { 39 | top: 65, 40 | width: '100%', 41 | position: 'fixed', 42 | zIndex: 1000, 43 | }, 44 | paper: { 45 | background: palette.secondary.light, 46 | }, 47 | editTagsField: { 48 | paddingBottom: spacing(1), 49 | marginBottom: spacing(3), 50 | }, 51 | tags: { 52 | display: 'flex', 53 | flexWrap: 'wrap', 54 | }, 55 | tag: { 56 | margin: spacing(0.25), 57 | fontWeight: 500, 58 | fontSize: 14, 59 | }, 60 | })); 61 | 62 | export default useStyles; 63 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["react-hooks"], 3 | "rules": { 4 | "semi": ["error", "always"], 5 | "@typescript-eslint/ban-ts-ignore": "off", 6 | "@typescript-eslint/no-var-requires": "off", 7 | "@typescript-eslint/no-explicit-any": "off", 8 | "@typescript-eslint/explicit-function-return-type": "off", 9 | "@typescript-eslint/no-use-before-define": "off", 10 | "react-hooks/rules-of-hooks": "error", 11 | "react-hooks/exhaustive-deps": "warn" 12 | }, 13 | "extends": [ 14 | "plugin:react/recommended", // Uses the recommended rules from @eslint-plugin-react 15 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 16 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 17 | "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 18 | ], 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features 22 | "sourceType": "module", // Allows for the use of imports 23 | "ecmaFeatures": { 24 | "jsx": true // Allows for the parsing of JSX, 25 | } 26 | }, 27 | "settings": { 28 | "react": { 29 | "version": "detect" // Tells eslint-plugin-react to automatically detect the version of React to use 30 | } 31 | }, 32 | "env": { 33 | "browser": true, 34 | "node": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactElement } from 'react'; 2 | import { hydrate } from 'react-dom'; 3 | import { createStore, applyMiddleware } from 'redux'; 4 | import thunk from 'redux-thunk'; 5 | import { Provider } from 'react-redux'; 6 | import { Route, BrowserRouter } from 'react-router-dom'; 7 | import { CookiesProvider } from 'react-cookie'; 8 | import { ThemeProvider } from '@material-ui/core/styles'; 9 | 10 | import rootReducer from '@core/reducers'; 11 | import App from '@ui/app'; 12 | import { logger, crashReporter } from '@utils/reduxMiddlewares'; 13 | import theme from '@core/theme'; 14 | 15 | // Grab the state from a global variable injected into the server-generated HTML 16 | const preloadedState = window.__PRELOADED_STATE__; 17 | 18 | // Allow the passed state to be garbage-collected 19 | delete window.__PRELOADED_STATE__; 20 | 21 | // Create Redux store with initial state 22 | const store = createStore( 23 | rootReducer, 24 | preloadedState, 25 | applyMiddleware(thunk, logger, crashReporter), 26 | ); 27 | 28 | const Routes = (): ReactElement => { 29 | // remove the css sent inline in the html on client side 30 | // useEffect in similar to componentDidMount for function components 31 | useEffect(() => { 32 | const jssStyles = document.querySelector('#jss-server-side'); 33 | if (jssStyles && jssStyles.parentNode) jssStyles.parentNode.removeChild(jssStyles); 34 | }, []); 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | hydrate(, document.getElementById('root')); 50 | -------------------------------------------------------------------------------- /src/ui/common/placeholders/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import useStyles from './style'; 3 | import Skeleton from '@material-ui/lab/Skeleton'; 4 | import { Grid, Box, CardActionArea, CardHeader } from '@material-ui/core'; 5 | 6 | type BreakPoints = boolean | 12 | 6 | 4 | 2 | 'auto' | 1 | 3 | 5 | 7 | 8 | 9 | 10 | 11 | undefined; 7 | 8 | type CardListProps = { 9 | xs?: BreakPoints; 10 | sm?: BreakPoints; 11 | md?: BreakPoints; 12 | count?: number; 13 | }; 14 | 15 | export function CardListPlaceholder({ 16 | xs = 12, 17 | sm = 6, 18 | md = 4, 19 | count = 6, 20 | }: CardListProps): ReactElement { 21 | const classes = useStyles(); 22 | return ( 23 | 24 | {new Array(count).fill(0).map((_, index) => ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ))} 35 | 36 | ); 37 | } 38 | 39 | export function AuthorPlaceholder({ 40 | width = 40, 41 | height = 40, 42 | }: { 43 | width?: number; 44 | height?: number; 45 | }): ReactElement { 46 | const classes = useStyles(); 47 | return ( 48 |
49 | 50 | } 52 | title={} 53 | subheader={} 54 | /> 55 | 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/ui/common/confirmation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, MouseEvent } from 'react'; 2 | import { 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | Button, 9 | Paper, 10 | PaperProps, 11 | } from '@material-ui/core'; 12 | import useStyles from './style'; 13 | 14 | type Props = { 15 | open: boolean; 16 | onClose?: (event: MouseEvent) => void; 17 | title: string; 18 | message: string; 19 | onPositiveAction: (event: MouseEvent) => void; 20 | onNegativeAction: (event: MouseEvent) => void; 21 | positionText?: string; 22 | negativeText?: string; 23 | }; 24 | 25 | export default function ConfirmationDialog({ 26 | open, 27 | onClose, 28 | title, 29 | message, 30 | onPositiveAction, 31 | onNegativeAction, 32 | positionText = 'Yes', 33 | negativeText = 'No', 34 | }: Props): ReactElement { 35 | const classes = useStyles(); 36 | return ( 37 | 45 | {title} 46 | 47 | {message} 48 | 49 | 50 | 53 | 56 | 57 | 58 | ); 59 | } 60 | 61 | const PaperComponent = (props: PaperProps) => ; 62 | -------------------------------------------------------------------------------- /src/app-types.d.ts: -------------------------------------------------------------------------------- 1 | import { Action as ReduxAction } from 'redux'; 2 | import { ThunkAction } from 'redux-thunk'; 3 | import { RootState } from './reducers'; 4 | 5 | declare interface Action { 6 | readonly type: string; 7 | readonly payload?: T; 8 | } 9 | 10 | declare type Dispatch = (_: Action) => void; 11 | 12 | declare type StateFetcher = () => RootState; 13 | 14 | declare type AsyncAction = ThunkAction>; 15 | 16 | declare global { 17 | interface Window { 18 | __PRELOADED_STATE__: any; 19 | } 20 | } 21 | 22 | declare module '@material-ui/core/styles/createMuiTheme' { 23 | interface Theme { 24 | custom: { 25 | colors: { 26 | blueLight: React.CSSProperties['color']; 27 | }; 28 | }; 29 | } 30 | // allow configuration using `createMuiTheme` 31 | interface ThemeOptions { 32 | custom: { 33 | colors: { 34 | blueLight: React.CSSProperties['color']; 35 | }; 36 | }; 37 | } 38 | } 39 | 40 | export type Role = { 41 | _id: string; 42 | code: string; 43 | }; 44 | 45 | export type User = { 46 | _id: string; 47 | name: string; 48 | roles: Array; 49 | profilePicUrl?: string; 50 | }; 51 | 52 | export type Message = { 53 | text: string; 54 | type: 'success' | 'warning' | 'error' | 'info'; 55 | }; 56 | 57 | export interface Author { 58 | _id: string; 59 | name: string; 60 | profilePicUrl: string; 61 | } 62 | 63 | export interface Blog { 64 | _id: string; 65 | tags: Array; 66 | likes: number; 67 | score: number; 68 | title: string; 69 | description: string; 70 | author: Author; 71 | blogUrl: string; 72 | imgUrl: string; 73 | publishedAt: string; 74 | text?: string; 75 | } 76 | 77 | export interface BlogDetail extends Blog { 78 | isSubmitted: boolean; 79 | isDraft: boolean; 80 | isPublished: boolean; 81 | draftText: string; 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/common/preview/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, forwardRef, Ref, MouseEvent } from 'react'; 2 | import { 3 | Slide, 4 | Dialog, 5 | AppBar, 6 | Toolbar, 7 | IconButton, 8 | Typography, 9 | Button, 10 | Grid, 11 | Paper, 12 | PaperProps, 13 | } from '@material-ui/core'; 14 | import useStyles from './style'; 15 | import { Close as CloseIcon } from '@material-ui/icons'; 16 | import { BlogDetail } from 'app-types'; 17 | import { TransitionProps } from '@material-ui/core/transitions'; 18 | import Markdown from '@ui/common/markdown'; 19 | 20 | const Transition = forwardRef(function Transition( 21 | props: TransitionProps & { children?: ReactElement }, 22 | ref: Ref, 23 | ) { 24 | return ; 25 | }); 26 | 27 | type Props = { blog: BlogDetail; open: boolean; onClose: (event: MouseEvent) => void }; 28 | 29 | export default function BlogPreview({ blog, open, onClose }: Props): ReactElement { 30 | const classes = useStyles(); 31 | return ( 32 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | {blog.title} 46 | 47 | 50 | 51 | 52 | 53 | 54 | {blog?.draftText && } 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | const PaperComponent = (props: PaperProps) => ; 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Project 107 | .idea 108 | *.iml 109 | .DS_Store 110 | config.js 111 | 112 | # compliled by webpack 113 | dist 114 | build 115 | -------------------------------------------------------------------------------- /src/ui/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, StateFetcher } from 'app-types'; 2 | import { networkActionsCreator, actionCreator } from '@utils/creator'; 3 | import { publicRequest, protectedRequest } from '@utils/network'; 4 | import { AuthData } from './reducer'; 5 | import { validateToken } from '@utils/appUtils'; 6 | 7 | export const removeMessage = actionCreator('AUTH_REMOVE_MESSAGE'); 8 | export const updateAuthData = actionCreator('AUTH_UPDATE_DATA'); 9 | export const forceLogout = actionCreator('AUTH_FORCED_LOGOUT'); 10 | export const loginActions = networkActionsCreator('AUTH_LOGIN'); 11 | export const logoutActions = networkActionsCreator('AUTH_LOGOUT'); 12 | 13 | export type LoginRequestBody = { 14 | email: string; 15 | password: string; 16 | }; 17 | 18 | export type SignupRequestBody = { 19 | name: string; 20 | email: string; 21 | password: string; 22 | profilePicUrl?: string; 23 | }; 24 | 25 | export const basicSignup = (body: SignupRequestBody): AsyncAction => async (dispatch: Dispatch) => 26 | authRequest(body, 'signup/basic', dispatch); 27 | 28 | export const basicLogin = (body: LoginRequestBody): AsyncAction => async (dispatch: Dispatch) => 29 | authRequest(body, 'login/basic', dispatch); 30 | 31 | const authRequest = async ( 32 | body: SignupRequestBody | LoginRequestBody, 33 | endpoint: string, 34 | dispatch: Dispatch, 35 | ) => { 36 | try { 37 | dispatch(loginActions.requesting.action()); 38 | const response = await publicRequest({ 39 | url: endpoint, 40 | method: 'POST', 41 | data: body, 42 | }); 43 | dispatch(loginActions.success.action(response)); 44 | } catch (e) { 45 | dispatch(loginActions.failure.action(e)); 46 | } 47 | }; 48 | 49 | export const logout = (): AsyncAction => async (dispatch: Dispatch, getState: StateFetcher) => { 50 | try { 51 | const token = validateToken(getState()); 52 | dispatch(logoutActions.requesting.action()); 53 | const response = await protectedRequest( 54 | { 55 | url: 'logout', 56 | method: 'DELETE', 57 | }, 58 | token, 59 | dispatch, 60 | ); 61 | dispatch(logoutActions.success.action(response)); 62 | } catch (e) { 63 | dispatch(logoutActions.failure.action(e)); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/server/routes.tsx: -------------------------------------------------------------------------------- 1 | import express, { Response, NextFunction } from 'express'; 2 | import pageBuilder from './pageBuilder'; 3 | import { PublicRequest } from 'server-types'; 4 | import { publicRequest } from '@utils/network'; 5 | import { defaultState as blogListDefaultState } from '@ui/blogpage/reducer'; 6 | import { Blog } from 'app-types'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get('/blog/:endpoint', sendBlogPage); 11 | router.get('/blogs', sendBlogsPage); 12 | router.get('*', sendPage); 13 | 14 | async function sendBlogPage(req: PublicRequest, res: Response) { 15 | try { 16 | const response = await publicRequest({ 17 | url: 'blog/url', 18 | method: 'GET', 19 | params: { 20 | endpoint: req.params.endpoint, 21 | }, 22 | }); 23 | if (!response.data) throw new Error('Not Found'); 24 | res.send( 25 | pageBuilder( 26 | req, 27 | { 28 | title: response.data.title, 29 | description: response.data.description, 30 | }, 31 | { 32 | blogState: { 33 | ...blogListDefaultState, 34 | isFetching: false, 35 | data: response.data, 36 | }, 37 | }, 38 | ), 39 | ); 40 | } catch (e) { 41 | sendNotFoundPage(res); 42 | } 43 | } 44 | 45 | async function sendBlogsPage(req: PublicRequest, res: Response, next: NextFunction) { 46 | try { 47 | const response = await publicRequest>({ 48 | url: 'blogs/latest', 49 | method: 'GET', 50 | params: { pageNumber: 1, pageItemCount: 1000 }, 51 | }); 52 | res.send( 53 | pageBuilder( 54 | req, 55 | { 56 | title: 'AfterAcademy | Open Source Blogs', 57 | description: 58 | 'AfterAcademy open source blogs and articles with latest developments and trends', 59 | }, 60 | { 61 | blogListState: { 62 | ...blogListDefaultState, 63 | isFetching: false, 64 | data: response.data ? response.data : null, 65 | }, 66 | }, 67 | ), 68 | ); 69 | } catch (e) { 70 | next(e); 71 | } 72 | } 73 | 74 | function sendPage(req: PublicRequest, res: Response) { 75 | res.send(pageBuilder(req)); 76 | } 77 | 78 | function sendNotFoundPage(res: Response) { 79 | res.redirect('/404'); 80 | } 81 | 82 | export default router; 83 | -------------------------------------------------------------------------------- /src/ui/landing/assets/mindorks-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mindorks-logo-secondary 5 | Created with Sketch. 6 | 7 | 21 | 22 | -------------------------------------------------------------------------------- /src/server/template.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { join } from 'path'; 3 | 4 | const isDev = process.env.NODE_ENV === 'development'; 5 | 6 | const SITE_TITLE = '%SITE_TITLE%'; 7 | const SITE_URL = '%SITE_URL%'; 8 | const SITE_COVER_IMG_URL = '%SITE_COVER_IMG_URL%'; 9 | const SITE_DESCRIPTION = '%SITE_DESCRIPTION%'; 10 | const SITE_CSS = '/*SITE_CSS*/'; 11 | const APP_HTML = 'APP_HTML'; 12 | const SITE_PRELOADED_STATE = 'SITE_PRELOADED_STATE'; 13 | const SITE_SCRIPTS = ''; 14 | const DEV_SCRIPTS = 15 | ""; 16 | 17 | export interface RenderOption { 18 | html: string; 19 | css: string; 20 | preloadedState: any; 21 | siteUrl: string; 22 | title: string; 23 | coverImg: string; 24 | description: string; 25 | } 26 | 27 | export const loadTemplateBlocking = () => { 28 | try { 29 | const htmlPath = isDev 30 | ? join(__dirname, '../../public/template.html') 31 | : join(__dirname, '../../dist/template.html'); 32 | if (!fs.existsSync(htmlPath)) { 33 | console.log(`${htmlPath} does not exists, loadTemplate failed`); 34 | return process.exit(); 35 | } 36 | const html = fs.readFileSync(htmlPath, 'utf8'); 37 | if (!html) { 38 | console.log(`${htmlPath} does not exists, file empty`); 39 | return process.exit(); 40 | } 41 | return isDev ? html.replace(SITE_SCRIPTS, DEV_SCRIPTS) : html; 42 | } catch (e) { 43 | console.error(e); 44 | process.exit(); 45 | } 46 | }; 47 | 48 | function replace(str: string, mapObj: any) { 49 | const re = new RegExp( 50 | Object.keys(mapObj) 51 | .map((key) => escapeRegExp(key)) 52 | .join('|'), 53 | 'gi', 54 | ); 55 | return str.replace(re, function (matched) { 56 | return mapObj[matched]; 57 | }); 58 | } 59 | 60 | function escapeRegExp(str: string) { 61 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 62 | } 63 | 64 | export default function render({ 65 | html, 66 | css, 67 | preloadedState, 68 | siteUrl, 69 | title, 70 | coverImg, 71 | description, 72 | }: RenderOption): string { 73 | const htmlTemplate = global.htmlTemplate; 74 | return replace(htmlTemplate, { 75 | [SITE_TITLE]: title, 76 | [SITE_URL]: siteUrl, 77 | [SITE_COVER_IMG_URL]: coverImg, 78 | [SITE_DESCRIPTION]: description, 79 | [SITE_PRELOADED_STATE]: JSON.stringify(preloadedState).replace(/ appState); 24 | const { data: authData, isLoggedIn } = useStateSelector(({ authState }) => authState); 25 | 26 | // only run on the client 27 | if (typeof window !== 'undefined' && willMount.current) { 28 | const authData = cookies[KEY_AUTH_DATA]; 29 | if (authData) dispatch(updateAuthData.action(authData)); 30 | willMount.current = false; 31 | } 32 | 33 | useEffect(() => { 34 | removeAppLoader(); 35 | // eslint-disable-next-line react-hooks/exhaustive-deps 36 | }, []); 37 | 38 | useEffect(() => { 39 | if (currentPageTitle) setPageTitle(currentPageTitle); 40 | }, [currentPageTitle]); 41 | 42 | useEffect(() => { 43 | scrollPageToTop(); 44 | }, [match]); 45 | 46 | useEffect(() => { 47 | if (isLoggedIn) { 48 | setAuthCookies(); 49 | } else { 50 | removeAuthCookies(); 51 | } 52 | // eslint-disable-next-line react-hooks/exhaustive-deps 53 | }, [isLoggedIn]); 54 | 55 | const setAuthCookies = () => { 56 | if (authData?.tokens?.accessToken) { 57 | const expiryInSec = 30 * 24 * 60 * 60; // 30 days 58 | setCookie(KEY_AUTH_DATA, authData, { 59 | path: '/', 60 | maxAge: expiryInSec, 61 | sameSite: 'strict', 62 | secure: process.env.NODE_ENV === 'production', // only https access allowed 63 | }); 64 | } 65 | }; 66 | 67 | const removeAuthCookies = () => { 68 | removeCookie(KEY_AUTH_DATA, { path: '/' }); 69 | }; 70 | 71 | return ( 72 | 73 | 74 |
75 |
76 |
77 | 78 |
79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | export default hot(App); 86 | -------------------------------------------------------------------------------- /src/ui/blogpage/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { removeMessage, clearPage, fetchBlogByEndpoint } from './actions'; 5 | import { useStateSelector } from '@core/reducers'; 6 | import { useRouteMatch } from 'react-router-dom'; 7 | import Snackbar from '@ui/common/snackbar'; 8 | import { CardActionArea, Avatar, Grid, LinearProgress, CardHeader } from '@material-ui/core'; 9 | import { convertToReadableDate } from '@utils/appUtils'; 10 | import { AuthorPlaceholder } from '@ui/common/placeholders'; 11 | import Skeleton from '@material-ui/lab/Skeleton'; 12 | import Markdown from '@ui/common/markdown'; 13 | import FirstLetter from '@ui/common/firstletter'; 14 | 15 | export default function BlogPage(): ReactElement { 16 | const classes = useStyles(); 17 | const match = useRouteMatch<{ endpoint: string }>(); 18 | const { data, isFetching, message } = useStateSelector((state) => state.blogState); 19 | const dispatch = useDispatch(); 20 | 21 | useEffect(() => { 22 | const endpoint = match.params.endpoint; 23 | if (endpoint) 24 | if (!data) { 25 | dispatch(fetchBlogByEndpoint(endpoint)); 26 | } else if (data.blogUrl !== endpoint) { 27 | dispatch(clearPage.action(endpoint)); 28 | dispatch(fetchBlogByEndpoint(endpoint)); 29 | } 30 | // eslint-disable-next-line react-hooks/exhaustive-deps 31 | }, [match.params.endpoint]); 32 | 33 | const authorView = data ? ( 34 |
35 | 36 | 40 | ) : ( 41 | 42 | ) 43 | } 44 | title={data.author.name} 45 | subheader={convertToReadableDate(data.publishedAt)} 46 | /> 47 | 48 |
49 | ) : null; 50 | 51 | return ( 52 |
53 | {isFetching && } 54 | 55 | 56 | {isFetching ? : authorView} 57 | {isFetching ? ( 58 | 59 | ) : ( 60 | data && data.text && 61 | )} 62 | 63 | 64 | {message && ( 65 | dispatch(removeMessage.action())} 69 | /> 70 | )} 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/creator.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'app-types'; 2 | import { NetworkResponse } from '@utils/network'; 3 | 4 | export function actionCreator(actionType: string) { 5 | interface SingleAction extends Action { 6 | readonly type: typeof actionType; 7 | readonly payload?: T; 8 | } 9 | 10 | const actionGenerator = (data?: T): SingleAction => ({ 11 | type: actionType, 12 | payload: data, 13 | }); 14 | 15 | type ActionWrapper = { 16 | readonly type: typeof actionType; 17 | readonly action: (data?: T) => SingleAction; 18 | }; 19 | 20 | const actionWrapper: ActionWrapper = { 21 | type: actionType, 22 | action: actionGenerator, 23 | }; 24 | 25 | return actionWrapper; 26 | } 27 | 28 | export function networkActionsCreator(actionType: string) { 29 | const requesting = actionType + '_REQUESTING'; 30 | const success = actionType + '_SUCCESS'; 31 | const failure = actionType + '_FAILURE'; 32 | 33 | interface RequestingAction extends Action { 34 | readonly type: typeof requesting; 35 | } 36 | 37 | interface SuccessAction extends Action { 38 | readonly type: typeof success; 39 | readonly payload: NetworkResponse; 40 | } 41 | 42 | interface FailureAction extends Action { 43 | readonly type: typeof failure; 44 | readonly payload: NetworkResponse; 45 | } 46 | 47 | const requestingActionGenerator = (): RequestingAction => ({ 48 | type: requesting, 49 | }); 50 | 51 | const successActionGenerator = (response: NetworkResponse): SuccessAction => ({ 52 | type: success, 53 | payload: response, 54 | }); 55 | 56 | const failureActionGenerator = (response: NetworkResponse): FailureAction => ({ 57 | type: failure, 58 | payload: response, 59 | }); 60 | 61 | type RequestActionWrapper = { 62 | readonly type: typeof requesting; 63 | readonly action: () => RequestingAction; 64 | }; 65 | 66 | type SuccessActionWrapper = { 67 | readonly type: typeof success; 68 | readonly action: (response: NetworkResponse) => SuccessAction; 69 | }; 70 | 71 | type FailureActionWrapper = { 72 | readonly type: typeof requesting; 73 | readonly action: (response: NetworkResponse) => FailureAction; 74 | }; 75 | 76 | type ActionWrappers = { 77 | readonly requesting: RequestActionWrapper; 78 | readonly success: SuccessActionWrapper; 79 | readonly failure: FailureActionWrapper; 80 | }; 81 | 82 | const actionWrappers: ActionWrappers = { 83 | requesting: { 84 | type: requesting, 85 | action: requestingActionGenerator, 86 | }, 87 | success: { 88 | type: success, 89 | action: successActionGenerator, 90 | }, 91 | failure: { 92 | type: failure, 93 | action: failureActionGenerator, 94 | }, 95 | }; 96 | 97 | return actionWrappers; 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/writer/myblogs/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, StateFetcher, BlogDetail } from 'app-types'; 2 | import { actionCreator, networkActionsCreator } from '@utils/creator'; 3 | import { validateToken } from '@utils/appUtils'; 4 | import { protectedRequest } from '@utils/network'; 5 | 6 | export const removeMessage = actionCreator('WRITER_REMOVE_MESSAGE'); 7 | export const clearPage = actionCreator('WRITER_CLEAR_PAGE'); 8 | export const deleteBlogActions = networkActionsCreator('WRITER_DELETE_BLOG'); 9 | export const draftBlogsActions = networkActionsCreator>('WRITER_DRAFT_BLOGS'); 10 | export const submittedBlogsActions = networkActionsCreator>( 11 | 'WRITER_SUBMITTED_BLOGS', 12 | ); 13 | export const publishedBlogsActions = networkActionsCreator>( 14 | 'WRITER_PUBLISHED_BLOGS', 15 | ); 16 | 17 | export const fetchSubmittedBlogs = (): AsyncAction => async ( 18 | dispatch: Dispatch, 19 | getState: StateFetcher, 20 | ) => fetchBlogs(submittedBlogsActions, 'writer/blog/submitted/all', dispatch, getState); 21 | 22 | export const fetchDraftBlogs = (): AsyncAction => async ( 23 | dispatch: Dispatch, 24 | getState: StateFetcher, 25 | ) => fetchBlogs(draftBlogsActions, 'writer/blog/drafts/all', dispatch, getState); 26 | 27 | export const fetchPublishedBlogs = (): AsyncAction => async ( 28 | dispatch: Dispatch, 29 | getState: StateFetcher, 30 | ) => fetchBlogs(publishedBlogsActions, 'writer/blog/published/all', dispatch, getState); 31 | 32 | const fetchBlogs = async ( 33 | networkActions: 34 | | typeof submittedBlogsActions 35 | | typeof draftBlogsActions 36 | | typeof publishedBlogsActions, 37 | endoint: string, 38 | dispatch: Dispatch, 39 | getState: StateFetcher, 40 | ) => { 41 | try { 42 | const token = validateToken(getState()); 43 | dispatch(networkActions.requesting.action()); 44 | const response = await protectedRequest>( 45 | { 46 | url: endoint, 47 | method: 'GET', 48 | }, 49 | token, 50 | dispatch, 51 | ); 52 | dispatch(networkActions.success.action(response)); 53 | } catch (e) { 54 | dispatch(networkActions.failure.action(e)); 55 | } 56 | }; 57 | 58 | export const deleteBlog = (blog: BlogDetail): AsyncAction => async ( 59 | dispatch: Dispatch, 60 | getState: StateFetcher, 61 | ) => { 62 | try { 63 | const token = validateToken(getState()); 64 | dispatch(deleteBlogActions.requesting.action()); 65 | const response = await protectedRequest( 66 | { 67 | url: 'writer/blog/id/' + blog._id, 68 | method: 'DELETE', 69 | }, 70 | token, 71 | dispatch, 72 | ); 73 | dispatch(deleteBlogActions.success.action({ ...response, data: blog })); 74 | } catch (e) { 75 | dispatch(deleteBlogActions.failure.action(e)); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/ui/auth/reducer.ts: -------------------------------------------------------------------------------- 1 | import { updateAuthData, forceLogout, loginActions, logoutActions, removeMessage } from './actions'; 2 | import { Action, User, Message } from 'app-types'; 3 | 4 | export enum Roles { 5 | LEARNER = 'LEARNER', 6 | WRITER = 'WRITER', 7 | EDITOR = 'EDITOR', 8 | } 9 | 10 | export type AuthData = { 11 | user: User; 12 | tokens: { 13 | accessToken: string; 14 | }; 15 | }; 16 | 17 | export type State = { 18 | data: AuthData | null; 19 | isLoggingIn: boolean; 20 | isLoggingOut: boolean; 21 | isLoggedIn: boolean; 22 | isForcedLogout: boolean; 23 | isRedirectHome: boolean; 24 | message: Message | null; 25 | }; 26 | 27 | export const defaultState: State = { 28 | data: null, 29 | isLoggingIn: false, 30 | isLoggingOut: false, 31 | isLoggedIn: false, 32 | isForcedLogout: false, 33 | isRedirectHome: false, 34 | message: null, 35 | }; 36 | 37 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 38 | switch (type) { 39 | case removeMessage.type: { 40 | return { 41 | ...state, 42 | message: null, 43 | }; 44 | } 45 | case updateAuthData.type: 46 | if (!payload) return { ...defaultState }; 47 | return { 48 | ...state, 49 | data: payload, 50 | isLoggedIn: true, 51 | isForcedLogout: false, 52 | }; 53 | case forceLogout.type: 54 | return { ...state, isForcedLogout: true }; 55 | case loginActions.requesting.type: 56 | return { 57 | ...state, 58 | isLoggingIn: true, 59 | }; 60 | case loginActions.success.type: 61 | return { 62 | ...state, 63 | data: payload.data, 64 | isLoggingIn: false, 65 | isLoggedIn: true, 66 | isForcedLogout: false, 67 | message: { text: payload.message, type: 'success' }, 68 | }; 69 | case loginActions.failure.type: 70 | return { 71 | ...state, 72 | isLoggingIn: false, 73 | message: { text: payload.message, type: 'error' }, 74 | }; 75 | case logoutActions.requesting.type: 76 | return { 77 | ...state, 78 | isLoggingOut: true, 79 | }; 80 | case logoutActions.success.type: 81 | return { 82 | ...state, 83 | data: null, 84 | isLoggingOut: false, 85 | isLoggedIn: false, 86 | isForcedLogout: false, 87 | message: { text: payload.message, type: 'success' }, 88 | }; 89 | case logoutActions.failure.type: 90 | return { 91 | ...state, 92 | isLoggingOut: false, 93 | isLoggedIn: false, 94 | isForcedLogout: false, 95 | message: { text: payload.message, type: 'error' }, 96 | }; 97 | default: 98 | return state; 99 | } 100 | }; 101 | 102 | export default reducer; 103 | -------------------------------------------------------------------------------- /src/ui/common/snackbar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, MouseEvent, useState, SyntheticEvent } from 'react'; 2 | import useStyles from './style'; 3 | import clsx from 'clsx'; 4 | import { 5 | CheckCircle as CheckCircleIcon, 6 | Warning as WarningIcon, 7 | Error as ErrorIcon, 8 | Info as InfoIcon, 9 | Close as CloseIcon, 10 | } from '@material-ui/icons'; 11 | import { 12 | SnackbarContent, 13 | IconButton, 14 | Snackbar as MaterialSnackbar, 15 | SnackbarCloseReason, 16 | } from '@material-ui/core'; 17 | 18 | const variantIcon = { 19 | success: CheckCircleIcon, 20 | warning: WarningIcon, 21 | error: ErrorIcon, 22 | info: InfoIcon, 23 | }; 24 | 25 | type PropSnackbarContent = { 26 | className?: string; 27 | message: string; 28 | onClose?: (event: MouseEvent) => void; 29 | variant?: 'success' | 'warning' | 'error' | 'info'; 30 | }; 31 | 32 | function SnackbarContentWrapper({ 33 | className, 34 | message, 35 | onClose, 36 | variant = 'success', 37 | ...other 38 | }: PropSnackbarContent): ReactElement { 39 | const classes = useStyles(); 40 | const Icon = variantIcon[variant]; 41 | 42 | return ( 43 | 48 | 49 | {message} 50 | 51 | } 52 | action={[ 53 | 54 | 55 | , 56 | ]} 57 | {...other} 58 | /> 59 | ); 60 | } 61 | 62 | type PropSnackbar = { 63 | message: string; 64 | onClose?: () => void; 65 | variant?: 'success' | 'warning' | 'error' | 'info'; 66 | }; 67 | 68 | export default function Snackbar({ message, onClose, variant }: PropSnackbar): ReactElement { 69 | const [open, setOpen] = useState(true); 70 | 71 | const handleClose: (event: SyntheticEvent, reason: SnackbarCloseReason | null) => void = ( 72 | event, 73 | reason, 74 | ) => { 75 | if (reason === 'clickaway') return; 76 | setOpen(false); 77 | if (onClose) onClose(); 78 | }; 79 | 80 | return ( 81 |
82 | 91 | handleClose(event, null)} 93 | variant={variant === undefined ? 'success' : variant} 94 | message={message} 95 | /> 96 | 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/utils/network.ts: -------------------------------------------------------------------------------- 1 | import axios, { Method } from 'axios'; 2 | import { forceLogout } from '@ui/auth/actions'; 3 | import { Dispatch } from 'app-types'; 4 | 5 | const isLogEnabled = process.env.NODE_ENV !== 'production' && process.env.LOGGING == 'true'; 6 | 7 | const instance = axios.create({ 8 | baseURL: process.env.API_BASE_URL, 9 | timeout: 10000, 10 | headers: { 11 | 'x-api-key': process.env.API_KEY, 12 | 'Content-Type': 'application/json', 13 | }, 14 | maxContentLength: 5 * 1000 * 1000, // bytes => 5 MB 15 | }); 16 | 17 | // Add a request interceptor 18 | instance.interceptors.request.use( 19 | (config) => { 20 | // Do something before request is sent 21 | if (isLogEnabled) 22 | console.log('Network Request:', `${config.baseURL}${config.url}`, config.method); 23 | return config; 24 | }, 25 | async (error) => { 26 | if (isLogEnabled) console.error('Network Request:', error); 27 | throw error; 28 | }, 29 | ); 30 | 31 | // Add a response interceptor 32 | instance.interceptors.response.use( 33 | (response) => { 34 | // Any status code that lie within the range of 2xx cause this function to trigger 35 | // Do something with response data 36 | return response; 37 | }, 38 | async (error) => { 39 | // Any status codes that falls outside the range of 2xx cause this function to trigger 40 | // Do something with response error 41 | if (isLogEnabled) console.error('Network Response:', error); 42 | throw error && error.response && error.response.data; 43 | }, 44 | ); 45 | 46 | export type NetworkResponse = { 47 | readonly statusCode: string; 48 | readonly message: string; 49 | readonly data?: T; 50 | }; 51 | 52 | export interface NetworkRequest { 53 | url: string; 54 | method: Method; 55 | data?: T; 56 | params?: object; 57 | } 58 | 59 | export interface NetworkAuthRequest extends NetworkRequest { 60 | headers?: { Authorization: string }; 61 | } 62 | 63 | /** 64 | * @T : Request Body Type 65 | * @R : Response Body type 66 | */ 67 | export async function publicRequest( 68 | request: NetworkRequest, 69 | ): Promise> { 70 | const { data } = await instance.request>(request); 71 | return data; 72 | } 73 | 74 | /** 75 | * @T : Request Body Type 76 | * @R : Response Body type 77 | */ 78 | export async function protectedRequest( 79 | request: NetworkRequest, 80 | token: string, 81 | dispatch: Dispatch, 82 | ): Promise> { 83 | try { 84 | (request as NetworkAuthRequest).headers = { Authorization: `Bearer ${token}` }; 85 | const { data } = await instance.request>(request); 86 | return data; 87 | } catch (e) { 88 | if (e.response && e.response.status === '401') dispatch(forceLogout.action()); 89 | throw e; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /public/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %SITE_TITLE% 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 38 | 39 | 40 | 84 | 85 | 86 | 87 |
88 |
89 |
90 |
91 |
92 |
APP_HTML
93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/ui/footer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import useStyles from './style'; 4 | import { Divider, Link, Grid, Typography } from '@material-ui/core'; 5 | import { Copyright } from '@material-ui/icons'; 6 | 7 | export default function Component(): ReactElement { 8 | const classes = useStyles(); 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | Copyright 2020 18 | 19 | 20 | MindOrks Nextgen Private Limited 21 |
22 | Gurgaon, Haryana, India 23 |
24 |
25 |
26 | 27 | 28 | Quick Links 29 | 30 | 31 |
32 | 33 |
34 | 35 |
36 | 37 |
38 |
39 | 40 | 41 | About Us 42 | 43 | 44 |
45 | 46 |
47 | 51 |
52 | 53 |
54 | 55 | 56 | Free Resources 57 | 58 | 59 |
60 | 61 |
62 | 63 |
64 | 65 |
66 |
67 |
68 | ); 69 | } 70 | 71 | const ExternalLink = ({ href, name }: { href: string; name: string }) => ( 72 | 73 | {name} 74 | 75 | ); 76 | 77 | const InternalLink = ({ link, name }: { link: string; name: string }) => ( 78 | 79 | {name} 80 | 81 | ); 82 | -------------------------------------------------------------------------------- /src/server/pageBuilder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStore, applyMiddleware } from 'redux'; 3 | import { Provider } from 'react-redux'; 4 | import { renderToString } from 'react-dom/server'; 5 | import { CookiesProvider } from 'react-cookie'; 6 | import { StaticRouter } from 'react-router-dom'; 7 | import { minify as minifyHtml } from 'html-minifier'; 8 | import { ServerStyleSheets, ThemeProvider } from '@material-ui/core/styles'; 9 | import render from './template'; 10 | import configureStore from './devStoreConfig'; 11 | import theme from '@core/theme'; 12 | import rootReducer, { RootState } from '@core/reducers'; 13 | import App, { KEY_AUTH_DATA } from '@ui/app'; 14 | import { PublicRequest } from 'server-types'; 15 | import thunk from 'redux-thunk'; 16 | 17 | const isDev = process.env.NODE_ENV === 'development'; 18 | 19 | export const getProtocol = (req: PublicRequest): string => { 20 | // @ts-ignore 21 | let proto: string = req.connection.encrypted ? 'https' : 'http'; 22 | // only do this if you trust the proxy 23 | const forwarded = req.headers['x-forwarded-proto']; 24 | if (forwarded) proto = forwarded.toString(); 25 | return proto.split(/\s*,\s*/)[0]; 26 | }; 27 | 28 | export const buildUrl = (req: PublicRequest, endpoint: string): string => { 29 | const baseUrl = `${getProtocol(req)}://${req.get('host')}/`; 30 | return `${baseUrl}${endpoint}`; 31 | }; 32 | 33 | export type PageInfo = { 34 | title: string; 35 | description: string; 36 | coverImg?: string; 37 | }; 38 | 39 | export default function pageBuilder( 40 | req: PublicRequest, 41 | pageinfo: PageInfo = { 42 | title: 'AfterAcademy | React Project', 43 | description: 'This is the sample project to learn and implement React app.', 44 | }, 45 | currentState: Partial = {}, 46 | ): string { 47 | // create mui server style 48 | const sheets = new ServerStyleSheets(); 49 | 50 | const authData = req.universalCookies.get(KEY_AUTH_DATA); 51 | if (authData?.tokens?.accessToken) { 52 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 53 | const { tokens, ...data } = authData; 54 | currentState.authState = { 55 | data: data, // security 56 | isLoggingIn: false, 57 | isLoggingOut: false, 58 | isLoggedIn: true, 59 | isForcedLogout: false, 60 | isRedirectHome: false, 61 | message: null, 62 | }; 63 | } 64 | 65 | const store = isDev 66 | ? configureStore(currentState) 67 | : createStore(rootReducer, currentState, applyMiddleware(thunk)); 68 | 69 | // Render the component to a string 70 | const html = renderToString( 71 | sheets.collect( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | , 81 | ), 82 | ); 83 | 84 | // Grab the CSS from our sheets. 85 | const css = sheets.toString(); 86 | 87 | const baseUrl = `${getProtocol(req)}://${req.get('host')}`; 88 | const siteUrl = baseUrl + req.originalUrl; 89 | 90 | const { coverImg, title, description } = pageinfo; 91 | 92 | let htmlPage = render({ 93 | html: html, 94 | css: css, 95 | preloadedState: store.getState(), 96 | siteUrl: siteUrl, 97 | title: title, 98 | coverImg: coverImg ? coverImg : `${baseUrl}/assets/og-cover-image.jpg`, 99 | description: description, 100 | }); 101 | 102 | try { 103 | htmlPage = minifyHtml(htmlPage, { 104 | minifyCSS: true, 105 | minifyJS: true, 106 | }); 107 | } catch (e) { 108 | console.log(e); 109 | } 110 | 111 | return htmlPage; 112 | } 113 | -------------------------------------------------------------------------------- /src/ui/writer/myblogs/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | removeMessage, 3 | submittedBlogsActions, 4 | draftBlogsActions, 5 | publishedBlogsActions, 6 | deleteBlogActions, 7 | clearPage, 8 | } from './actions'; 9 | import { Action, Message, BlogDetail } from 'app-types'; 10 | 11 | export type BlogsData = { 12 | drafts?: Array; 13 | submissions?: Array; 14 | published?: Array; 15 | }; 16 | 17 | export type State = { 18 | data: BlogsData | null; 19 | isFetchingBlog: boolean; 20 | isDeletingBlog: boolean; 21 | isFetchingDrafts: boolean; 22 | isFetchingSubmissions: boolean; 23 | isFetchingPublished: boolean; 24 | message: Message | null; 25 | }; 26 | 27 | export const defaultState: State = { 28 | data: null, 29 | isFetchingBlog: false, 30 | isDeletingBlog: false, 31 | isFetchingDrafts: false, 32 | isFetchingSubmissions: false, 33 | isFetchingPublished: false, 34 | message: null, 35 | }; 36 | 37 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 38 | switch (type) { 39 | case removeMessage.type: 40 | return { 41 | ...state, 42 | message: null, 43 | }; 44 | case clearPage.type: 45 | return { 46 | ...defaultState, 47 | }; 48 | // Handle draft blogs data 49 | case draftBlogsActions.requesting.type: 50 | return { 51 | ...state, 52 | isFetchingDrafts: true, 53 | }; 54 | case draftBlogsActions.failure.type: 55 | return { 56 | ...state, 57 | isFetchingDrafts: false, 58 | message: { 59 | type: 'error', 60 | text: payload.message, 61 | }, 62 | }; 63 | case draftBlogsActions.success.type: 64 | return { 65 | ...state, 66 | isFetchingDrafts: false, 67 | data: { 68 | ...state.data, 69 | drafts: payload.data, 70 | }, 71 | }; 72 | // Handle submitted blogs data 73 | case submittedBlogsActions.requesting.type: 74 | return { 75 | ...state, 76 | isFetchingSubmissions: true, 77 | }; 78 | case submittedBlogsActions.failure.type: 79 | return { 80 | ...state, 81 | isFetchingSubmissions: false, 82 | message: { 83 | type: 'error', 84 | text: payload.message, 85 | }, 86 | }; 87 | case submittedBlogsActions.success.type: 88 | return { 89 | ...state, 90 | isFetchingSubmissions: false, 91 | data: { 92 | ...state.data, 93 | submissions: payload.data, 94 | }, 95 | }; 96 | // Handle published blogs data 97 | case publishedBlogsActions.requesting.type: 98 | return { 99 | ...state, 100 | isFetchingPublished: true, 101 | }; 102 | case publishedBlogsActions.failure.type: 103 | return { 104 | ...state, 105 | isFetchingPublished: false, 106 | message: { 107 | type: 'error', 108 | text: payload.message, 109 | }, 110 | }; 111 | case publishedBlogsActions.success.type: 112 | return { 113 | ...state, 114 | isFetchingPublished: false, 115 | data: { 116 | ...state.data, 117 | published: payload.data, 118 | }, 119 | }; 120 | // Handle blog delete 121 | case deleteBlogActions.requesting.type: 122 | return { 123 | ...state, 124 | isDeletingBlog: true, 125 | }; 126 | case deleteBlogActions.failure.type: 127 | return { 128 | ...state, 129 | isDeletingBlog: false, 130 | message: { 131 | type: 'error', 132 | text: payload.message, 133 | }, 134 | }; 135 | case deleteBlogActions.success.type: 136 | return { 137 | ...state, 138 | isDeletingBlog: false, 139 | data: { 140 | ...state.data, 141 | drafts: state.data?.drafts?.filter((blog) => blog._id !== payload.data._id), 142 | }, 143 | }; 144 | default: 145 | return state; 146 | } 147 | }; 148 | 149 | export default reducer; 150 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme, responsiveFontSizes } from '@material-ui/core/styles'; 2 | import { red } from '@material-ui/core/colors'; 3 | 4 | /** 5 | * @reference https://material-ui.com/customization/default-theme 6 | * @colors https://material-ui.com/customization/color/#color-tool 7 | */ 8 | const theme = createMuiTheme({ 9 | palette: { 10 | type: 'dark', 11 | primary: { 12 | light: '#ffac33', 13 | main: '#ff9800', 14 | dark: '#b26a00', 15 | contrastText: '#000', 16 | }, 17 | secondary: { 18 | light: '#1C2226', 19 | main: '#12121c', 20 | dark: '#0c0c13', 21 | contrastText: '#fff', 22 | }, 23 | error: { 24 | main: red.A400, 25 | }, 26 | background: { 27 | paper: '#121212', 28 | default: '#1C2226', 29 | }, 30 | }, 31 | shape: { 32 | borderRadius: 4, 33 | }, 34 | custom: { 35 | colors: { 36 | blueLight: 'aliceBlue', 37 | }, 38 | }, 39 | typography: { 40 | fontFamily: [ 41 | 'Roboto', 42 | '-apple-system', 43 | 'BlinkMacSystemFont', 44 | '"Segoe UI"', 45 | '"Helvetica Neue"', 46 | 'Arial', 47 | 'sans-serif', 48 | '"Apple Color Emoji"', 49 | '"Segoe UI Emoji"', 50 | '"Segoe UI Symbol"', 51 | ].join(','), 52 | }, 53 | shadows: [ 54 | 'none', 55 | '0px 1px 3px 0px rgba(0,0,0,0.12),0px 1px 1px 0px rgba(0,0,0,0.10),0px 2px 1px -1px rgba(0,0,0,0.08)', 56 | '0px 1px 5px 0px rgba(0,0,0,0.12),0px 2px 2px 0px rgba(0,0,0,0.10),0px 3px 1px -2px rgba(0,0,0,0.08)', 57 | '0px 1px 8px 0px rgba(0,0,0,0.12),0px 3px 4px 0px rgba(0,0,0,0.10),0px 3px 3px -2px rgba(0,0,0,0.08)', 58 | '0px 2px 4px -1px rgba(0,0,0,0.12),0px 4px 5px 0px rgba(0,0,0,0.10),0px 1px 10px 0px rgba(0,0,0,0.08)', 59 | '0px 3px 5px -1px rgba(0,0,0,0.12),0px 5px 8px 0px rgba(0,0,0,0.10),0px 1px 14px 0px rgba(0,0,0,0.08)', 60 | '0px 3px 5px -1px rgba(0,0,0,0.12),0px 6px 10px 0px rgba(0,0,0,0.10),0px 1px 18px 0px rgba(0,0,0,0.08)', 61 | '0px 4px 5px -2px rgba(0,0,0,0.12),0px 7px 10px 1px rgba(0,0,0,0.10),0px 2px 16px 1px rgba(0,0,0,0.08)', 62 | '0px 5px 5px -3px rgba(0,0,0,0.12),0px 8px 10px 1px rgba(0,0,0,0.10),0px 3px 14px 2px rgba(0,0,0,0.08)', 63 | '0px 5px 6px -3px rgba(0,0,0,0.12),0px 9px 12px 1px rgba(0,0,0,0.10),0px 3px 16px 2px rgba(0,0,0,0.08)', 64 | '0px 6px 6px -3px rgba(0,0,0,0.12),0px 10px 14px 1px rgba(0,0,0,0.10),0px 4px 18px 3px rgba(0,0,0,0.08)', 65 | '0px 6px 7px -4px rgba(0,0,0,0.12),0px 11px 15px 1px rgba(0,0,0,0.10),0px 4px 20px 3px rgba(0,0,0,0.08)', 66 | '0px 7px 8px -4px rgba(0,0,0,0.12),0px 12px 17px 2px rgba(0,0,0,0.10),0px 5px 22px 4px rgba(0,0,0,0.08)', 67 | '0px 7px 8px -4px rgba(0,0,0,0.12),0px 13px 19px 2px rgba(0,0,0,0.10),0px 5px 24px 4px rgba(0,0,0,0.08)', 68 | '0px 7px 9px -4px rgba(0,0,0,0.12),0px 14px 21px 2px rgba(0,0,0,0.10),0px 5px 26px 4px rgba(0,0,0,0.08)', 69 | '0px 8px 9px -5px rgba(0,0,0,0.12),0px 15px 22px 2px rgba(0,0,0,0.10),0px 6px 28px 5px rgba(0,0,0,0.08)', 70 | '0px 8px 10px -5px rgba(0,0,0,0.12),0px 16px 24px 2px rgba(0,0,0,0.10),0px 6px 30px 5px rgba(0,0,0,0.08)', 71 | '0px 8px 11px -5px rgba(0,0,0,0.12),0px 17px 26px 2px rgba(0,0,0,0.10),0px 6px 32px 5px rgba(0,0,0,0.08)', 72 | '0px 9px 11px -5px rgba(0,0,0,0.12),0px 18px 28px 2px rgba(0,0,0,0.10),0px 7px 34px 6px rgba(0,0,0,0.08)', 73 | '0px 9px 12px -6px rgba(0,0,0,0.12),0px 19px 29px 2px rgba(0,0,0,0.10),0px 7px 36px 6px rgba(0,0,0,0.08)', 74 | '0px 10px 13px -6px rgba(0,0,0,0.12),0px 20px 31px 3px rgba(0,0,0,0.10),0px 8px 38px 7px rgba(0,0,0,0.08)', 75 | '0px 10px 13px -6px rgba(0,0,0,0.12),0px 21px 33px 3px rgba(0,0,0,0.10),0px 8px 40px 7px rgba(0,0,0,0.08)', 76 | '0px 10px 14px -6px rgba(0,0,0,0.12),0px 22px 35px 3px rgba(0,0,0,0.10),0px 8px 42px 7px rgba(0,0,0,0.08)', 77 | '0px 11px 14px -7px rgba(0,0,0,0.12),0px 23px 36px 3px rgba(0,0,0,0.10),0px 9px 44px 8px rgba(0,0,0,0.08)', 78 | '0px 11px 15px -7px rgba(0,0,0,0.12),0px 24px 38px 3px rgba(0,0,0,0.10),0px 9px 46px 8px rgba(0,0,0,0.08)', 79 | ], 80 | overrides: { 81 | //The overrides key enables you to customize 82 | // the appearance of all instances of a component type, 83 | // while the props key enables you to change 84 | // the default value(s) of a component's props. 85 | }, 86 | }); 87 | 88 | export default responsiveFontSizes(theme); 89 | -------------------------------------------------------------------------------- /src/ui/editor/blogs/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, StateFetcher, BlogDetail } from 'app-types'; 2 | import { actionCreator, networkActionsCreator } from '@utils/creator'; 3 | import { validateToken } from '@utils/appUtils'; 4 | import { protectedRequest } from '@utils/network'; 5 | 6 | export const removeMessage = actionCreator('EDITOR_REMOVE_MESSAGE'); 7 | export const clearPage = actionCreator('EDITOR_CLEAR_PAGE'); 8 | export const clearBlog = actionCreator('EDITOR_CLEAR_BLOG'); 9 | export const blogActions = networkActionsCreator('EDITOR_BLOG'); 10 | export const publishBlogActions = networkActionsCreator('EDITOR_PUBLISH_BLOG'); 11 | export const unpublishBlogActions = networkActionsCreator('EDITOR_UNPUBLISH_BLOG'); 12 | export const submittedBlogsActions = networkActionsCreator>( 13 | 'EDITOR_SUBMITTED_BLOGS', 14 | ); 15 | export const publishedBlogsActions = networkActionsCreator>( 16 | 'EDITOR_PUBLISHED_BLOGS', 17 | ); 18 | 19 | export const fetchSubmittedBlogs = (): AsyncAction => async ( 20 | dispatch: Dispatch, 21 | getState: StateFetcher, 22 | ) => fetchBlogs(submittedBlogsActions, 'editor/blog/submitted/all', dispatch, getState); 23 | 24 | export const fetchPublishedBlogs = (): AsyncAction => async ( 25 | dispatch: Dispatch, 26 | getState: StateFetcher, 27 | ) => fetchBlogs(publishedBlogsActions, 'editor/blog/published/all', dispatch, getState); 28 | 29 | const fetchBlogs = async ( 30 | networkActions: typeof submittedBlogsActions | typeof publishedBlogsActions, 31 | endoint: string, 32 | dispatch: Dispatch, 33 | getState: StateFetcher, 34 | ) => { 35 | try { 36 | const token = validateToken(getState()); 37 | dispatch(networkActions.requesting.action()); 38 | const response = await protectedRequest>( 39 | { 40 | url: endoint, 41 | method: 'GET', 42 | }, 43 | token, 44 | dispatch, 45 | ); 46 | dispatch(networkActions.success.action(response)); 47 | } catch (e) { 48 | dispatch(networkActions.failure.action(e)); 49 | } 50 | }; 51 | 52 | export const publishBlog = (blog: BlogDetail): AsyncAction => async ( 53 | dispatch: Dispatch, 54 | getState: StateFetcher, 55 | ) => { 56 | try { 57 | const token = validateToken(getState()); 58 | dispatch(publishBlogActions.requesting.action()); 59 | const response = await protectedRequest( 60 | { 61 | url: `editor/blog/publish/${blog._id}`, 62 | method: 'PUT', 63 | }, 64 | token, 65 | dispatch, 66 | ); 67 | dispatch( 68 | publishBlogActions.success.action({ 69 | ...response, 70 | data: { ...blog, isPublished: true, isSubmitted: false }, 71 | }), 72 | ); 73 | } catch (e) { 74 | dispatch(publishBlogActions.failure.action(e)); 75 | } 76 | }; 77 | 78 | export const unpublishBlog = (blog: BlogDetail): AsyncAction => async ( 79 | dispatch: Dispatch, 80 | getState: StateFetcher, 81 | ) => { 82 | try { 83 | const token = validateToken(getState()); 84 | dispatch(unpublishBlogActions.requesting.action()); 85 | const response = await protectedRequest( 86 | { 87 | url: `editor/blog/unpublish/${blog._id}`, 88 | method: 'PUT', 89 | }, 90 | token, 91 | dispatch, 92 | ); 93 | dispatch( 94 | unpublishBlogActions.success.action({ 95 | ...response, 96 | data: { ...blog, isPublished: false, isSubmitted: true }, 97 | }), 98 | ); 99 | } catch (e) { 100 | dispatch(unpublishBlogActions.failure.action(e)); 101 | } 102 | }; 103 | 104 | export const fetchBlog = (blog: BlogDetail): AsyncAction => async ( 105 | dispatch: Dispatch, 106 | getState: StateFetcher, 107 | ) => { 108 | try { 109 | const token = validateToken(getState()); 110 | dispatch(blogActions.requesting.action()); 111 | const response = await protectedRequest( 112 | { 113 | url: 'editor/blog/id/' + blog._id, 114 | method: 'GET', 115 | }, 116 | token, 117 | dispatch, 118 | ); 119 | dispatch(blogActions.success.action(response)); 120 | } catch (e) { 121 | dispatch(blogActions.failure.action(e)); 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /src/ui/bloglist/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { fetchLatestBlogs, removeMessage } from './actions'; 5 | import { useStateSelector } from '@core/reducers'; 6 | import Snackbar from '@ui/common/snackbar'; 7 | import { CardListPlaceholder } from '@ui/common/placeholders'; 8 | import { 9 | Grid, 10 | Typography, 11 | Card, 12 | CardActionArea, 13 | CardMedia, 14 | CardContent, 15 | CardHeader, 16 | Avatar, 17 | } from '@material-ui/core'; 18 | import importer from '@utils/importer'; 19 | import { Blog } from 'app-types'; 20 | import { Link } from 'react-router-dom'; 21 | import { convertToReadableDate } from '@utils/appUtils'; 22 | import FirstLetter from '@ui/common/firstletter'; 23 | 24 | export default function BlogList(): ReactElement { 25 | const classes = useStyles(); 26 | const { data, isFetching, message } = useStateSelector((state) => state.blogListState); 27 | const dispatch = useDispatch(); 28 | importer('./assets/blog-page-cover.jpg'); 29 | 30 | useEffect(() => { 31 | if (!data && !isFetching) dispatch(fetchLatestBlogs()); 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | }, []); 34 | 35 | const blogCover = ( 36 |
37 |
38 | 39 | AfterAcademy 40 | 41 | 42 | Open Source Blogs 43 | 44 |
45 |
46 | ); 47 | 48 | return ( 49 |
50 | {blogCover} 51 | 52 | 53 | 54 | {isFetching && } 55 | {data && 56 | data.map((blog) => ( 57 | 58 | 59 | 60 | ))} 61 | 62 | 63 | 64 | {message && ( 65 | dispatch(removeMessage.action())} 69 | /> 70 | )} 71 |
72 | ); 73 | } 74 | 75 | export const BlogCard = ({ 76 | blog, 77 | selection, 78 | }: { 79 | blog: Blog; 80 | selection?: (blog: Blog) => void; 81 | }): ReactElement => { 82 | const classes = useStyles(); 83 | 84 | const { title, description, author, imgUrl, blogUrl, publishedAt } = blog; 85 | 86 | return ( 87 | { 91 | selection && selection(blog); 92 | }} 93 | > 94 | 95 | 102 | 103 | 104 | {title} 105 | 106 | 112 | {description} 113 | 114 | 115 | 120 | ) : ( 121 | 122 | ) 123 | } 124 | title={author.name} 125 | subheader={convertToReadableDate(publishedAt)} 126 | /> 127 | 128 | 129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /src/ui/header/assets/afteracademy-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | afteracademy-logo 4 | Created by AfterAcademy. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/utils/reduxMiddlewares.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logs all actions and states after they are dispatched. 3 | */ 4 | const LOGGING = process.env.NODE_ENV !== 'production' && process.env.LOGGING == 'true'; 5 | 6 | export const logger = (store: any) => (next: any) => (action: any) => { 7 | if (LOGGING) { 8 | console.group(action.type); 9 | console.info('dispatching', action); 10 | const result = next(action); 11 | console.log('next state', store.getState()); 12 | console.groupEnd(); 13 | return result; 14 | } 15 | return next(action); 16 | }; 17 | 18 | /** 19 | * Sends crash reports as state is updated and listeners are notified. 20 | */ 21 | export const crashReporter = () => (next: any) => (action: any) => { 22 | if (LOGGING) { 23 | try { 24 | return next(action); 25 | } catch (err) { 26 | console.error('Caught an exception!', err); 27 | // Raven.captureException(err, { 28 | // extra: { 29 | // action, 30 | // state: store.getState() 31 | // } 32 | // }) 33 | throw err; 34 | } 35 | } 36 | return next(action); 37 | }; 38 | 39 | /** 40 | * Schedules actions with { meta: { delay: N } } to be delayed by N milliseconds. 41 | * Makes `dispatch` return a function to cancel the timeout in this case. 42 | */ 43 | export const timeoutScheduler = () => (next: any) => (action: any) => { 44 | if (!action.meta || !action.meta.delay) { 45 | return next(action); 46 | } 47 | 48 | const timeoutId = setTimeout(() => next(action), action.meta.delay); 49 | 50 | return function cancel() { 51 | clearTimeout(timeoutId); 52 | }; 53 | }; 54 | 55 | /** 56 | * Schedules actions with { meta: { raf: true } } to be dispatched inside a rAF loop 57 | * frame. Makes `dispatch` return a function to remove the action from the queue in 58 | * this case. 59 | */ 60 | export const rafScheduler = () => (next: any) => { 61 | let queuedActions: any[] = []; 62 | let frame: number | null = null; 63 | 64 | function loop() { 65 | frame = null; 66 | try { 67 | if (queuedActions.length) { 68 | next(queuedActions.shift()); 69 | } 70 | } finally { 71 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 72 | maybeRaf(); 73 | } 74 | } 75 | 76 | function maybeRaf() { 77 | if (queuedActions.length && !frame) { 78 | frame = requestAnimationFrame(loop); 79 | } 80 | } 81 | 82 | return (action: any) => { 83 | if (!action.meta || !action.meta.raf) { 84 | return next(action); 85 | } 86 | 87 | queuedActions.push(action); 88 | maybeRaf(); 89 | 90 | return function cancel() { 91 | queuedActions = queuedActions.filter((a) => a !== action); 92 | }; 93 | }; 94 | }; 95 | 96 | /** 97 | * Lets you dispatch promises in addition to actions. 98 | * If the promise is resolved, its result will be dispatched as an action. 99 | * The promise is returned from `dispatch` so the caller may handle rejection. 100 | */ 101 | export const vanillaPromise = (store: any) => (next: any) => (action: any) => { 102 | if (typeof action.then !== 'function') { 103 | return next(action); 104 | } 105 | 106 | return Promise.resolve(action).then(store.dispatch); 107 | }; 108 | 109 | /** 110 | * Lets you dispatch special actions with a { promise } field. 111 | * 112 | * This middleware will turn them into a single action at the beginning, 113 | * and a single success (or failure) action when the `promise` resolves. 114 | * 115 | * For convenience, `dispatch` will return the promise so the caller can wait. 116 | */ 117 | export const readyStatePromise = () => (next: any) => (action: any) => { 118 | if (!action.promise) { 119 | return next(action); 120 | } 121 | 122 | function makeAction(ready: any, data: any) { 123 | const newAction = Object.assign({}, action, { ready }, data); 124 | delete newAction.promise; 125 | return newAction; 126 | } 127 | 128 | next(makeAction(false, null)); 129 | return action.promise.then( 130 | (result: any) => next(makeAction(true, { result })), 131 | (error: any) => next(makeAction(true, { error })), 132 | ); 133 | }; 134 | 135 | /** 136 | * Lets you dispatch a function instead of an action. 137 | * This function will receive `dispatch` and `getState` as arguments. 138 | * 139 | * Useful for early exits (conditions over `getState()`), as well 140 | * as for async control flow (it can `dispatch()` something else). 141 | * 142 | * `dispatch` will return the return value of the dispatched function. 143 | */ 144 | export const thunk = (store: any) => (next: any) => (action: any) => 145 | typeof action === 'function' ? action(store.dispatch, store.getState) : next(action); 146 | -------------------------------------------------------------------------------- /src/ui/writer/writingpad/actions.ts: -------------------------------------------------------------------------------- 1 | import { AsyncAction, Dispatch, StateFetcher, BlogDetail, Blog } from 'app-types'; 2 | import { actionCreator, networkActionsCreator } from '@utils/creator'; 3 | import { validateToken } from '@utils/appUtils'; 4 | import { protectedRequest } from '@utils/network'; 5 | import { clearPage } from '@ui/writer/myblogs/actions'; 6 | 7 | export const showMessage = actionCreator('WRITING_PAD_SHOW_MESSAGE'); 8 | export const removeMessage = actionCreator('WRITING_PAD_REMOVE_MESSAGE'); 9 | export const clearPad = actionCreator('WRITING_PAD_CLEAR'); 10 | export const hydratePad = actionCreator('WRITING_PAD_HYDRATE'); 11 | export const editBlog = actionCreator>('WRITING_PAD_BLOG_UPDATE'); 12 | 13 | export const blogActions = networkActionsCreator('WRITING_PAD_BLOG'); 14 | export const createBlogActions = networkActionsCreator('WRITING_PAD_BLOG_CREATE'); 15 | export const saveBlogActions = networkActionsCreator('WRITING_PAD_BLOG_SAVE'); 16 | export const submitBlogActions = networkActionsCreator('WRITING_PAD_BLOG_SUBMIT'); 17 | export const withdrawBlogActions = networkActionsCreator('WRITING_PAD_BLOG_WITHDRAW'); 18 | 19 | export type BlogRequestBody = { 20 | tags: Array; 21 | score?: number; 22 | title: string; 23 | description: string; 24 | blogUrl?: string; 25 | imgUrl: string; 26 | text: string; 27 | }; 28 | 29 | export const fetchBlog = (blogId: string): AsyncAction => async ( 30 | dispatch: Dispatch, 31 | getState: StateFetcher, 32 | ) => { 33 | try { 34 | const token = validateToken(getState()); 35 | dispatch(blogActions.requesting.action()); 36 | const response = await protectedRequest( 37 | { 38 | url: 'writer/blog/id/' + blogId, 39 | method: 'GET', 40 | }, 41 | token, 42 | dispatch, 43 | ); 44 | dispatch(blogActions.success.action(response)); 45 | } catch (e) { 46 | dispatch(blogActions.failure.action(e)); 47 | } 48 | }; 49 | 50 | export const createBlog = (body: BlogRequestBody): AsyncAction => async ( 51 | dispatch: Dispatch, 52 | getState: StateFetcher, 53 | ) => { 54 | try { 55 | const token = validateToken(getState()); 56 | dispatch(createBlogActions.requesting.action()); 57 | const response = await protectedRequest( 58 | { 59 | url: 'writer/blog', 60 | method: 'POST', 61 | data: { ...body }, 62 | }, 63 | token, 64 | dispatch, 65 | ); 66 | dispatch(createBlogActions.success.action(response)); 67 | dispatch(clearPage.action()); 68 | } catch (e) { 69 | dispatch(createBlogActions.failure.action(e)); 70 | } 71 | }; 72 | 73 | export const saveBlog = (blogId: string, body: BlogRequestBody): AsyncAction => async ( 74 | dispatch: Dispatch, 75 | getState: StateFetcher, 76 | ) => { 77 | try { 78 | const token = validateToken(getState()); 79 | dispatch(saveBlogActions.requesting.action()); 80 | const response = await protectedRequest( 81 | { 82 | url: 'writer/blog/id/' + blogId, 83 | method: 'PUT', 84 | data: { ...body }, 85 | }, 86 | token, 87 | dispatch, 88 | ); 89 | dispatch(saveBlogActions.success.action(response)); 90 | dispatch(clearPage.action()); 91 | } catch (e) { 92 | dispatch(saveBlogActions.failure.action(e)); 93 | } 94 | }; 95 | 96 | export const submitBlog = (blogId: string): AsyncAction => async ( 97 | dispatch: Dispatch, 98 | getState: StateFetcher, 99 | ) => { 100 | try { 101 | const token = validateToken(getState()); 102 | dispatch(submitBlogActions.requesting.action()); 103 | const response = await protectedRequest( 104 | { 105 | url: 'writer/blog/submit/' + blogId, 106 | method: 'PUT', 107 | }, 108 | token, 109 | dispatch, 110 | ); 111 | dispatch(submitBlogActions.success.action(response)); 112 | dispatch(clearPage.action()); 113 | } catch (e) { 114 | dispatch(submitBlogActions.failure.action(e)); 115 | } 116 | }; 117 | 118 | export const withdrawBlog = (blogId: string): AsyncAction => async ( 119 | dispatch: Dispatch, 120 | getState: StateFetcher, 121 | ) => { 122 | try { 123 | const token = validateToken(getState()); 124 | dispatch(withdrawBlogActions.requesting.action()); 125 | const response = await protectedRequest( 126 | { 127 | url: 'writer/blog/withdraw/' + blogId, 128 | method: 'PUT', 129 | }, 130 | token, 131 | dispatch, 132 | ); 133 | dispatch(withdrawBlogActions.success.action(response)); 134 | dispatch(clearPage.action()); 135 | } catch (e) { 136 | dispatch(withdrawBlogActions.failure.action(e)); 137 | } 138 | }; 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app-architecture", 3 | "version": "1.0.0", 4 | "description": "React.js Web Application Architecture - Learn to build a complete website for a blogging platform like Medium, FreeCodeCamp, MindOrks etc using React.js, Redux, MaterialUI, and Webpack. OpenSource project by AfterAcademy", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npm run build && npm run serve", 8 | "serve": "NODE_ENV=production node -r dotenv/config build/server/server.prod.js", 9 | "devserver": "NODE_ENV=development node -r ./tools/babel-register.js src/server/server.dev.ts", 10 | "watch": "npm run clean && npm run devserver", 11 | "prebuild": "npm run clean", 12 | "bundle": "npm run clean && NODE_ENV=production webpack -p --config ./webpack.config.js", 13 | "build": "npm run bundle && npx babel src/ -d build/ --extensions '.js,.jsx,.ts,.tsx'", 14 | "clean": "rimraf ./dist && mkdir dist && rimraf ./build && mkdir build", 15 | "test": "jest --forceExit --detectOpenHandles --coverage --verbose", 16 | "eslint": "eslint . --ext .js,.ts,.jsx,.tsx" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/afteracademy/react-app-architecture.git" 21 | }, 22 | "author": "AfterAcademy", 23 | "license": "Apache-2.0", 24 | "bugs": { 25 | "url": "https://github.com/afteracademy/react-app-architecture/issues" 26 | }, 27 | "homepage": "https://github.com/afteracademy/react-app-architecture#readme", 28 | "dependencies": { 29 | "@date-io/core": "^2.6.0", 30 | "@date-io/moment": "^2.6.0", 31 | "@material-ui/core": "^4.9.11", 32 | "@material-ui/icons": "^4.9.1", 33 | "@material-ui/lab": "^4.0.0-alpha.50", 34 | "@material-ui/pickers": "^3.2.10", 35 | "@mdi/js": "^5.1.45", 36 | "axios": "^0.19.2", 37 | "clsx": "^1.1.0", 38 | "express": "^4.17.1", 39 | "hint.css": "^2.6.0", 40 | "jss": "^10.1.1", 41 | "marked": "^1.0.0", 42 | "material-ui-chip-input": "^1.1.0", 43 | "moment": "^2.24.0", 44 | "query-string": "^6.12.1", 45 | "react": "^16.13.1", 46 | "react-cookie": "^4.0.3", 47 | "react-dom": "^16.13.1", 48 | "react-join": "^1.1.4", 49 | "react-redux": "^7.2.0", 50 | "react-router-dom": "^5.1.2", 51 | "redux": "^4.0.5", 52 | "redux-thunk": "^2.3.0", 53 | "serve-favicon": "^2.5.0", 54 | "universal-cookie-express": "^4.0.3" 55 | }, 56 | "devDependencies": { 57 | "@babel/cli": "^7.8.4", 58 | "@babel/core": "^7.9.0", 59 | "@babel/plugin-proposal-class-properties": "^7.8.3", 60 | "@babel/plugin-proposal-object-rest-spread": "^7.9.5", 61 | "@babel/plugin-transform-runtime": "^7.9.0", 62 | "@babel/preset-env": "^7.9.5", 63 | "@babel/preset-react": "^7.9.4", 64 | "@babel/preset-typescript": "^7.9.0", 65 | "@babel/register": "^7.9.0", 66 | "@babel/runtime": "^7.9.2", 67 | "@hot-loader/react-dom": "^16.13.0", 68 | "@types/express": "^4.17.6", 69 | "@types/html-minifier": "^3.5.3", 70 | "@types/ignore-styles": "^5.0.0", 71 | "@types/jest": "^25.2.1", 72 | "@types/marked": "^0.7.4", 73 | "@types/material-ui": "^0.21.7", 74 | "@types/react": "^16.9.34", 75 | "@types/react-dom": "^16.9.6", 76 | "@types/react-redux": "^7.1.7", 77 | "@types/react-router-dom": "^5.1.4", 78 | "@types/serve-favicon": "^2.5.0", 79 | "@types/webpack": "^4.41.12", 80 | "@types/webpack-dev-middleware": "^3.7.0", 81 | "@types/webpack-hot-middleware": "^2.25.2", 82 | "@typescript-eslint/eslint-plugin": "^2.29.0", 83 | "@typescript-eslint/parser": "^2.29.0", 84 | "autoprefixer": "^9.7.6", 85 | "babel-loader": "^8.1.0", 86 | "babel-plugin-import": "^1.13.0", 87 | "babel-plugin-module-resolver": "^4.0.0", 88 | "copy-webpack-plugin": "^5.1.1", 89 | "css-loader": "^3.5.2", 90 | "cssnano": "^4.1.10", 91 | "dotenv": "^8.2.0", 92 | "eslint": "^6.8.0", 93 | "eslint-config-babel": "^9.0.0", 94 | "eslint-config-prettier": "^6.10.1", 95 | "eslint-plugin-import": "^2.20.2", 96 | "eslint-plugin-prettier": "^3.1.3", 97 | "eslint-plugin-react": "^7.19.0", 98 | "eslint-plugin-react-hooks": "^3.0.0", 99 | "file-loader": "^6.0.0", 100 | "html-loader": "^1.1.0", 101 | "html-minifier": "^4.0.0", 102 | "html-webpack-plugin": "^4.2.0", 103 | "ignore-styles": "^5.0.1", 104 | "jest": "^25.4.0", 105 | "mini-css-extract-plugin": "^0.9.0", 106 | "postcss-loader": "^3.0.0", 107 | "prettier": "^2.0.4", 108 | "react-hot-loader": "^4.12.20", 109 | "redux-devtools": "^3.5.0", 110 | "rimraf": "^3.0.2", 111 | "style-loader": "^1.1.4", 112 | "ts-jest": "^25.4.0", 113 | "typescript": "^3.8.3", 114 | "url-loader": "^4.1.0", 115 | "webpack": "^4.42.1", 116 | "webpack-cli": "^3.3.11", 117 | "webpack-dev-middleware": "^3.7.2", 118 | "webpack-hot-middleware": "^2.25.0" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ui/writer/writingpad/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | clearPad, 3 | hydratePad, 4 | showMessage, 5 | removeMessage, 6 | editBlog, 7 | blogActions, 8 | createBlogActions, 9 | saveBlogActions, 10 | submitBlogActions, 11 | withdrawBlogActions, 12 | } from './actions'; 13 | import { Action, Message, BlogDetail, Blog } from 'app-types'; 14 | 15 | export type State = { 16 | data: BlogDetail | null; 17 | hydrationBlog: Blog | null; 18 | isFetchingBlog: boolean; 19 | isSavingBlog: boolean; 20 | message: Message | null; 21 | }; 22 | 23 | export const defaultState: State = { 24 | data: null, 25 | hydrationBlog: null, 26 | isFetchingBlog: false, 27 | isSavingBlog: false, 28 | message: null, 29 | }; 30 | 31 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 32 | switch (type) { 33 | case hydratePad.type: 34 | return { 35 | ...defaultState, 36 | hydrationBlog: payload, 37 | }; 38 | case clearPad.type: 39 | return { 40 | ...defaultState, 41 | }; 42 | case showMessage.type: 43 | return { 44 | ...state, 45 | message: payload, 46 | }; 47 | case removeMessage.type: 48 | return { 49 | ...state, 50 | message: null, 51 | }; 52 | // Handle blog on change 53 | case editBlog.type: 54 | return { 55 | ...state, 56 | data: { ...state.data, ...payload }, 57 | }; 58 | // Handle blog fetch for edit 59 | case blogActions.requesting.type: 60 | return { 61 | ...state, 62 | isFetchingBlog: true, 63 | }; 64 | case blogActions.failure.type: 65 | return { 66 | ...state, 67 | isFetchingBlog: false, 68 | message: { 69 | type: 'error', 70 | text: payload.message, 71 | }, 72 | }; 73 | case blogActions.success.type: 74 | return { 75 | ...state, 76 | isFetchingBlog: false, 77 | data: payload.data, 78 | }; 79 | // Handle blog create 80 | case createBlogActions.requesting.type: 81 | return { 82 | ...state, 83 | isSavingBlog: true, 84 | }; 85 | case createBlogActions.failure.type: 86 | return { 87 | ...state, 88 | isSavingBlog: false, 89 | message: { 90 | type: 'error', 91 | text: payload.message, 92 | }, 93 | }; 94 | case createBlogActions.success.type: 95 | return { 96 | ...state, 97 | isSavingBlog: false, 98 | data: payload.data, 99 | message: { 100 | type: 'success', 101 | text: payload.message, 102 | }, 103 | }; 104 | // Handle blog save 105 | case saveBlogActions.requesting.type: 106 | return { 107 | ...state, 108 | isSavingBlog: true, 109 | }; 110 | case saveBlogActions.failure.type: 111 | return { 112 | ...state, 113 | isSavingBlog: false, 114 | message: { 115 | type: 'error', 116 | text: payload.message, 117 | }, 118 | }; 119 | case saveBlogActions.success.type: 120 | return { 121 | ...state, 122 | isSavingBlog: false, 123 | data: payload.data, 124 | message: { 125 | type: 'success', 126 | text: payload.message, 127 | }, 128 | }; 129 | // Handle blog submit 130 | case submitBlogActions.requesting.type: 131 | return { 132 | ...state, 133 | isSavingBlog: true, 134 | }; 135 | case submitBlogActions.failure.type: 136 | return { 137 | ...state, 138 | isSavingBlog: false, 139 | message: { 140 | type: 'error', 141 | text: payload.message, 142 | }, 143 | }; 144 | case submitBlogActions.success.type: 145 | return { 146 | ...state, 147 | isSavingBlog: false, 148 | data: state.data ? { ...state.data, isSubmitted: true } : state.data, 149 | message: { 150 | type: 'success', 151 | text: payload.message, 152 | }, 153 | }; 154 | // Handle blog withdraw 155 | case withdrawBlogActions.requesting.type: 156 | return { 157 | ...state, 158 | isSavingBlog: true, 159 | }; 160 | case withdrawBlogActions.failure.type: 161 | return { 162 | ...state, 163 | isSavingBlog: false, 164 | message: { 165 | type: 'error', 166 | text: payload.message, 167 | }, 168 | }; 169 | case withdrawBlogActions.success.type: 170 | return { 171 | ...state, 172 | isSavingBlog: false, 173 | data: state.data ? { ...state.data, isSubmitted: false } : state.data, 174 | message: { 175 | type: 'success', 176 | text: payload.message, 177 | }, 178 | }; 179 | default: 180 | return state; 181 | } 182 | }; 183 | 184 | export default reducer; 185 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const cssnano = require('cssnano'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const autoprefixer = require('autoprefixer'); 6 | const dotenv = require('dotenv'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 9 | 10 | // bake .env into the client code 11 | const configEnv = dotenv.config({ debug: true }).parsed; 12 | const envKeys = Object.keys(configEnv).reduce((result, key) => { 13 | result[`process.env.${key}`] = JSON.stringify(configEnv[key]); 14 | return result; 15 | }, {}); 16 | 17 | const isEnvDevelopment = process.env.NODE_ENV === 'development'; 18 | 19 | const srcPath = path.resolve(__dirname, './src'); 20 | const distPath = path.resolve(__dirname, './dist'); 21 | const templatePath = path.resolve(__dirname, './public/template.html'); 22 | const htmlPath = path.resolve(__dirname, './dist/template.html'); 23 | 24 | const extractStyle = new MiniCssExtractPlugin({ 25 | filename: isEnvDevelopment ? 'style/[name].css' : 'style/[name].[hash].css', 26 | chunkFilename: isEnvDevelopment ? 'style/[name].css' : 'style/[name].[hash].css', 27 | }); 28 | 29 | // used for those files which can't be loaded by url-loader 30 | const copyAssets = new CopyWebpackPlugin([ 31 | // Copy directory contents to {output}/to/directory/ 32 | { 33 | // from: 'assets', to: 'assets', // if the context directory has assets 34 | from: './assets', 35 | to: 'assets', 36 | }, 37 | ]); 38 | 39 | const cssLoaders = [ 40 | { loader: 'css-loader' }, 41 | { 42 | loader: 'postcss-loader', 43 | options: { 44 | plugins: () => 45 | isEnvDevelopment 46 | ? [autoprefixer] 47 | : [autoprefixer, cssnano({ discardComments: { removeAll: true, filterPlugins: false } })], 48 | }, 49 | }, 50 | ]; 51 | 52 | module.exports = { 53 | devtool: isEnvDevelopment ? 'inline-source-map' : false, 54 | mode: isEnvDevelopment ? 'development' : 'production', 55 | context: srcPath, 56 | entry: { 57 | app: isEnvDevelopment ? ['index.tsx', 'webpack-hot-middleware/client'] : 'index.tsx', 58 | }, 59 | output: { 60 | path: distPath, 61 | filename: isEnvDevelopment ? 'js/[name].js' : 'js/[name].[hash].js', 62 | publicPath: '/', 63 | }, 64 | plugins: [ 65 | copyAssets, 66 | extractStyle, 67 | new webpack.NamedModulesPlugin(), 68 | new webpack.DefinePlugin(envKeys), 69 | ].concat( 70 | isEnvDevelopment 71 | ? [new webpack.HotModuleReplacementPlugin()] 72 | : [ 73 | new HtmlWebpackPlugin({ 74 | template: templatePath, 75 | minify: false, 76 | filename: htmlPath, 77 | }), 78 | ], 79 | ), 80 | module: { 81 | rules: [ 82 | { 83 | test: /\.(ts|js)x?$/, 84 | exclude: [/node_modules/], 85 | use: [ 86 | { loader: 'babel-loader' }, 87 | { 88 | loader: path.resolve('./tools/importer-loader.js'), 89 | options: { 90 | functionName: 'importer', 91 | }, 92 | }, 93 | ], 94 | }, 95 | { 96 | test: /\.(css)$/, 97 | use: [MiniCssExtractPlugin.loader, ...cssLoaders], 98 | }, 99 | { 100 | test: /\.(eot|woff|woff2|ttf)$/, 101 | use: [ 102 | { 103 | loader: 'url-loader', 104 | options: { 105 | name: '[name].[ext]', 106 | publicPath: '/', 107 | outputPath: 'assets/', 108 | limit: 10 * 1000, //10 kb 109 | fallback: 'file-loader', 110 | }, 111 | }, 112 | ], 113 | }, 114 | { 115 | test: /\.(svg|png|jpg|jpeg|gif)$/, 116 | use: [ 117 | { 118 | loader: 'file-loader', 119 | options: { 120 | name: '[name].[ext]', 121 | publicPath: '/', 122 | outputPath: 'assets/', 123 | }, 124 | }, 125 | ], 126 | }, 127 | ], 128 | }, 129 | resolve: { 130 | modules: [srcPath, 'node_modules'], 131 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 132 | alias: { 'react-dom': '@hot-loader/react-dom' }, 133 | }, 134 | optimization: { 135 | minimize: !isEnvDevelopment, 136 | splitChunks: { 137 | cacheGroups: { 138 | commons: { 139 | name: 'commons', 140 | chunks: 'all', 141 | minChunks: 2, 142 | }, 143 | vendor: isEnvDevelopment 144 | ? { 145 | test: /node_modules/, 146 | name: 'vendor', 147 | chunks: 'all', 148 | enforce: true, 149 | } 150 | : { 151 | test: /node_modules/, 152 | name: 'vendor', 153 | chunks: 'all', 154 | enforce: true, 155 | minSize: 75 * 1000, // 75 kb 156 | maxSize: 200 * 1000, // 200 kb 157 | }, 158 | }, 159 | }, 160 | }, 161 | }; 162 | -------------------------------------------------------------------------------- /src/ui/editor/blogs/reducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | removeMessage, 3 | submittedBlogsActions, 4 | publishedBlogsActions, 5 | publishBlogActions, 6 | unpublishBlogActions, 7 | blogActions, 8 | clearPage, 9 | clearBlog, 10 | } from './actions'; 11 | import { Action, BlogDetail, Message } from 'app-types'; 12 | 13 | export type BlogsData = { 14 | submissions?: Array; 15 | published?: Array; 16 | }; 17 | 18 | export type State = { 19 | data: BlogsData | null; 20 | blog: BlogDetail | null; 21 | isFetchingBlog: boolean; 22 | isPublishingBlog: boolean; 23 | isUnpublishingBlog: boolean; 24 | isFetchingSubmissions: boolean; 25 | isFetchingPublished: boolean; 26 | message: Message | null; 27 | }; 28 | 29 | export const defaultState: State = { 30 | data: null, 31 | blog: null, 32 | isFetchingBlog: false, 33 | isPublishingBlog: false, 34 | isUnpublishingBlog: false, 35 | isFetchingSubmissions: false, 36 | isFetchingPublished: false, 37 | message: null, 38 | }; 39 | 40 | const reducer = (state: State = defaultState, { type, payload }: Action): State => { 41 | switch (type) { 42 | case removeMessage.type: 43 | return { 44 | ...state, 45 | message: null, 46 | }; 47 | case clearPage.type: 48 | return { 49 | ...defaultState, 50 | }; 51 | case clearBlog.type: 52 | return { 53 | ...state, 54 | blog: null, 55 | }; 56 | // Handle submitted blogs data 57 | case submittedBlogsActions.requesting.type: 58 | return { 59 | ...state, 60 | isFetchingSubmissions: true, 61 | }; 62 | case submittedBlogsActions.failure.type: 63 | return { 64 | ...state, 65 | isFetchingSubmissions: false, 66 | message: { 67 | type: 'error', 68 | text: payload.message, 69 | }, 70 | }; 71 | case submittedBlogsActions.success.type: 72 | return { 73 | ...state, 74 | isFetchingSubmissions: false, 75 | data: { 76 | ...state.data, 77 | submissions: payload.data, 78 | }, 79 | }; 80 | // Handle published blogs data 81 | case publishedBlogsActions.requesting.type: 82 | return { 83 | ...state, 84 | isFetchingPublished: true, 85 | }; 86 | case publishedBlogsActions.failure.type: 87 | return { 88 | ...state, 89 | isFetchingPublished: false, 90 | message: { 91 | type: 'error', 92 | text: payload.message, 93 | }, 94 | }; 95 | case publishedBlogsActions.success.type: 96 | return { 97 | ...state, 98 | isFetchingPublished: false, 99 | data: { 100 | ...state.data, 101 | published: payload.data, 102 | }, 103 | }; 104 | // Handle blog publish 105 | case publishBlogActions.requesting.type: 106 | return { 107 | ...state, 108 | isPublishingBlog: true, 109 | }; 110 | case publishBlogActions.failure.type: 111 | return { 112 | ...state, 113 | isPublishingBlog: false, 114 | message: { 115 | type: 'error', 116 | text: payload.message, 117 | }, 118 | }; 119 | case publishBlogActions.success.type: { 120 | const publishedBlog = state.data?.published?.filter((blog) => blog._id !== payload.data._id); 121 | return { 122 | ...state, 123 | isPublishingBlog: false, 124 | data: { 125 | ...state.data, 126 | submissions: state.data?.submissions?.filter((blog) => blog._id !== payload.data._id), 127 | published: publishedBlog ? [payload.data, ...publishedBlog] : [payload.data], 128 | }, 129 | }; 130 | } 131 | // Handle blog unpublish 132 | case unpublishBlogActions.requesting.type: 133 | return { 134 | ...state, 135 | isUnpublishingBlog: true, 136 | }; 137 | case unpublishBlogActions.failure.type: 138 | return { 139 | ...state, 140 | isUnpublishingBlog: false, 141 | message: { 142 | type: 'error', 143 | text: payload.message, 144 | }, 145 | }; 146 | case unpublishBlogActions.success.type: 147 | return { 148 | ...state, 149 | isUnpublishingBlog: false, 150 | data: { 151 | ...state.data, 152 | submissions: state.data?.submissions?.filter((blog) => blog._id !== payload.data._id), 153 | published: state.data?.published?.filter((blog) => blog._id !== payload.data._id), 154 | }, 155 | }; 156 | // Handle blog fetch for preview 157 | case blogActions.requesting.type: 158 | return { 159 | ...state, 160 | isFetchingBlog: true, 161 | }; 162 | case blogActions.failure.type: 163 | return { 164 | ...state, 165 | isFetchingBlog: false, 166 | message: { 167 | type: 'error', 168 | text: payload.message, 169 | }, 170 | }; 171 | case blogActions.success.type: 172 | return { 173 | ...state, 174 | isFetchingBlog: false, 175 | blog: payload.data, 176 | }; 177 | default: 178 | return state; 179 | } 180 | }; 181 | 182 | export default reducer; 183 | -------------------------------------------------------------------------------- /src/ui/auth/login.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState, ChangeEvent } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useStateSelector } from '@core/reducers'; 5 | import { validateEmail } from '@utils/appUtils'; 6 | import { basicLogin, removeMessage } from './actions'; 7 | import { 8 | LinearProgress, 9 | DialogTitle, 10 | Dialog, 11 | Typography, 12 | Divider, 13 | DialogContent, 14 | Button, 15 | DialogActions, 16 | TextField, 17 | Paper, 18 | PaperProps, 19 | Box, 20 | } from '@material-ui/core'; 21 | 22 | export default function LoginDialog({ 23 | open, 24 | onClose, 25 | onSignup, 26 | }: { 27 | open: boolean; 28 | onClose: () => void; 29 | onSignup: () => void; 30 | }): ReactElement | null { 31 | const { isLoggingIn } = useStateSelector(({ authState }) => authState); 32 | 33 | const classes = useStyles(); 34 | const dispatch = useDispatch(); 35 | 36 | const defaultFormState = { 37 | email: '', 38 | password: '', 39 | isEmailError: false, 40 | emailError: '', 41 | isPasswordError: false, 42 | passwordError: '', 43 | }; 44 | 45 | const [credentials, setCredentials] = useState(defaultFormState); 46 | 47 | const handleCredentialChange = (name: string) => (e: ChangeEvent) => { 48 | e.preventDefault(); // to prevent from loosing focus 49 | setCredentials({ 50 | ...credentials, 51 | isEmailError: false, 52 | emailError: '', 53 | isPasswordError: false, 54 | passwordError: '', 55 | [name]: e.target.value, 56 | }); 57 | }; 58 | 59 | const handleBasicLogin = () => { 60 | const validations = { ...credentials }; 61 | validations.email = validations.email.trim(); 62 | validations.password = validations.password.trim(); 63 | let error = false; 64 | 65 | if (validations.email.length === 0) { 66 | validations.emailError = 'Email should not be empty'; 67 | validations.isEmailError = true; 68 | error = true; 69 | } 70 | 71 | if (!validateEmail(validations.email)) { 72 | validations.emailError = 'Email is not valid'; 73 | validations.isEmailError = true; 74 | error = true; 75 | } 76 | 77 | if (validations.password.length < 6) { 78 | validations.passwordError = 'Password not valid'; 79 | validations.isPasswordError = true; 80 | error = true; 81 | } 82 | 83 | if (error) { 84 | setCredentials(validations); 85 | } else { 86 | dispatch(removeMessage.action()); 87 | dispatch( 88 | basicLogin({ 89 | email: credentials.email, 90 | password: credentials.password, 91 | }), 92 | ); 93 | } 94 | }; 95 | 96 | return ( 97 | 104 | {isLoggingIn && } 105 | 106 | {isLoggingIn ? ( 107 | 108 | Logging please wait... 109 | 110 | ) : ( 111 |
112 | 113 | 114 | 115 | Login Form 116 | 117 | 118 | 119 | 129 | 130 | 131 |
132 | )} 133 |
134 | 135 | 136 |
137 | 154 | 170 | 171 |
172 | 173 | 176 | 185 | 186 |
187 | ); 188 | } 189 | 190 | const PaperComponent = (props: PaperProps) => ; 191 | -------------------------------------------------------------------------------- /src/ui/writer/myblogs/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { 5 | removeMessage, 6 | deleteBlog, 7 | fetchDraftBlogs, 8 | fetchSubmittedBlogs, 9 | fetchPublishedBlogs, 10 | } from './actions'; 11 | import { hydratePad } from '@ui/writer/writingpad/actions'; 12 | import { useStateSelector } from '@core/reducers'; 13 | import Snackbar from '@ui/common/snackbar'; 14 | import NotFound from '@ui/notfound'; 15 | import ConfirmationDialog from '@ui/common/confirmation'; 16 | import { 17 | Tab, 18 | AppBar, 19 | Tabs, 20 | LinearProgress, 21 | Grid, 22 | List, 23 | ListItem, 24 | Typography, 25 | Chip, 26 | Button, 27 | } from '@material-ui/core'; 28 | import { BlogDetail } from 'app-types'; 29 | import { Link } from 'react-router-dom'; 30 | 31 | const tabNames = ['drafts', 'submissions', 'published']; 32 | 33 | type DialogState = { 34 | open: boolean; 35 | blog: BlogDetail | null; 36 | }; 37 | 38 | export default function MyBlogs(): ReactElement { 39 | const classes = useStyles(); 40 | const { 41 | data, 42 | isFetchingBlog, 43 | isDeletingBlog, 44 | isFetchingDrafts, 45 | isFetchingSubmissions, 46 | isFetchingPublished, 47 | message, 48 | } = useStateSelector((state) => state.writerBlogsState); 49 | 50 | const dispatch = useDispatch(); 51 | const [currentTab, setCurrentTab] = useState(0); 52 | const [deleteDialogState, setDeleteDialogState] = useState({ 53 | open: false, 54 | blog: null, 55 | }); 56 | 57 | useEffect(() => { 58 | if (!data) { 59 | !isFetchingDrafts && dispatch(fetchDraftBlogs()); 60 | !isFetchingSubmissions && dispatch(fetchSubmittedBlogs()); 61 | !isFetchingPublished && dispatch(fetchPublishedBlogs()); 62 | } 63 | // eslint-disable-next-line react-hooks/exhaustive-deps 64 | }, []); 65 | 66 | const getBlogsForTab = () => { 67 | if (!data) return null; 68 | let blogs = null; 69 | if (tabNames[currentTab] === 'drafts') blogs = data['drafts']; 70 | else if (tabNames[currentTab] === 'submissions') blogs = data['submissions']; 71 | else if (tabNames[currentTab] === 'published') blogs = data['published']; 72 | return blogs; 73 | }; 74 | 75 | const getContent = () => { 76 | if (!data) return null; 77 | const blogs = getBlogsForTab(); 78 | if (!blogs) return ; 79 | const content = blogs.map((blog) => ( 80 | 81 | 82 |
83 | 84 | {blog.title} 85 | 86 | {blog.isPublished === true && (blog.isDraft === true || blog.isSubmitted === true) && ( 87 | 94 | )} 95 | 96 | {blog.description} 97 | 98 | {blog.tags.map((tag) => ( 99 | 106 | ))} 107 |
108 | 121 | {!blog.isSubmitted && !blog.isPublished && ( 122 | 132 | )} 133 |
134 |
135 |
136 |
137 | )); 138 | if (content.length === 0) return ; 139 | return content; 140 | }; 141 | 142 | return ( 143 |
144 | 145 | setCurrentTab(newValue)} 148 | indicatorColor="primary" 149 | textColor="primary" 150 | variant="fullWidth" 151 | scrollButtons="auto" 152 | aria-label="blogs tab" 153 | > 154 | {tabNames.map((name) => ( 155 | 162 | ))} 163 | 164 | {(isFetchingDrafts || 165 | isFetchingSubmissions || 166 | isFetchingPublished || 167 | isFetchingBlog || 168 | isDeletingBlog) && } 169 | 170 | 171 | 172 | 173 | {getContent()} 174 | 175 | 176 | 177 | { 182 | if (deleteDialogState.blog) dispatch(deleteBlog(deleteDialogState.blog)); 183 | setDeleteDialogState({ open: false, blog: null }); 184 | }} 185 | onNegativeAction={() => setDeleteDialogState({ open: false, blog: null })} 186 | positionText="Delete Now" 187 | negativeText="Cancel" 188 | /> 189 | {message && ( 190 | dispatch(removeMessage.action())} 194 | /> 195 | )} 196 |
197 | ); 198 | } 199 | -------------------------------------------------------------------------------- /src/ui/writer/writingpad/form.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ChangeEvent, MouseEvent } from 'react'; 2 | import useStyles from './style'; 3 | import { BlogDetail } from 'app-types'; 4 | import { 5 | Dialog, 6 | DialogTitle, 7 | DialogContent, 8 | TextField, 9 | DialogActions, 10 | Button, 11 | Paper, 12 | PaperProps, 13 | } from '@material-ui/core'; 14 | import ChipInput from 'material-ui-chip-input'; 15 | import { LocalState } from './index'; 16 | import { validateUrl } from '@utils/appUtils'; 17 | 18 | type Props = { 19 | blog: BlogDetail | null; 20 | localState: LocalState; 21 | setLocalState: React.Dispatch>; 22 | onSave: () => void; 23 | onCreate: () => void; 24 | onSubmit: () => void; 25 | }; 26 | 27 | export default function BlogDetailsForm({ 28 | blog, 29 | localState, 30 | setLocalState, 31 | onSubmit, 32 | onSave, 33 | onCreate, 34 | }: Props): ReactElement { 35 | const classes = useStyles(); 36 | 37 | const handleBlogDetailChange = (name: string) => (event: ChangeEvent) => { 38 | event.preventDefault(); // to prevent from loosing focus 39 | let value = event.target.value; 40 | if (name == 'blogUrl') { 41 | value = value.replace(/\s/g, '-'); 42 | value = value.replace(/\//g, '-'); 43 | value = value.replace(/\./g, '-'); 44 | value = value.toLowerCase(); 45 | } 46 | 47 | setLocalState({ 48 | ...localState, 49 | [name]: value, 50 | isTitleError: false, 51 | isDescriptionError: false, 52 | isImgUrlError: false, 53 | isBlogUrlError: false, 54 | isTagsError: false, 55 | isAllDataSentToServer: false, 56 | }); 57 | }; 58 | 59 | const handleBlogDetailFormSubmit = (event: MouseEvent) => { 60 | event.preventDefault(); 61 | if (validateBlogDetailForm()) { 62 | if (!blog?._id) onCreate(); 63 | else onSave(); 64 | } 65 | }; 66 | 67 | const validateBlogDetailForm = () => { 68 | const newstate = { 69 | ...localState, 70 | isTitleError: false, 71 | isDescriptionError: false, 72 | isBlogUrlError: false, 73 | isImgUrlError: false, 74 | isTagsError: false, 75 | }; 76 | 77 | if (!newstate.title) newstate.isTitleError = true; 78 | if (!newstate.description) newstate.isDescriptionError = true; 79 | if (!newstate.blogUrl) newstate.isBlogUrlError = true; 80 | if (!validateUrl(newstate.imgUrl)) newstate.isImgUrlError = true; 81 | if (newstate.tags.length === 0) newstate.isTagsError = true; 82 | 83 | const isError = 84 | newstate.isTitleError || 85 | newstate.isDescriptionError || 86 | newstate.isBlogUrlError || 87 | newstate.isTagsError || 88 | newstate.isImgUrlError; 89 | 90 | setLocalState(newstate); 91 | return !isError; 92 | }; 93 | 94 | return ( 95 | 99 | setLocalState({ 100 | ...localState, 101 | isBlogDetailsFormToShow: false, 102 | isForSubmission: false, 103 | }) 104 | } 105 | PaperProps={{ className: classes.paper }} 106 | PaperComponent={PaperComponent} 107 | fullWidth={true} 108 | maxWidth="sm" 109 | scroll="body" 110 | aria-labelledby="form-dialog-title" 111 | aria-describedby="form-dialog-description" 112 | > 113 | Blog Details 114 | 115 |
116 | 129 | 144 | 157 | 170 | { 182 | const values = [...localState.tags]; 183 | values.push(chip.toUpperCase()); 184 | setLocalState({ ...localState, tags: values }); 185 | }} 186 | onDelete={(chip: string) => { 187 | const values = localState.tags.filter((tag) => tag !== chip); 188 | setLocalState({ ...localState, tags: values }); 189 | }} 190 | /> 191 | 192 |
193 | 194 | {blog?._id && localState.isForSubmission && localState.isAllDataSentToServer && ( 195 | 198 | )} 199 | {!localState.isAllDataSentToServer && ( 200 | 203 | )} 204 | 216 | 217 |
218 | ); 219 | } 220 | 221 | const PaperComponent = (props: PaperProps) => ; 222 | -------------------------------------------------------------------------------- /src/ui/landing/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import importer from '@utils/importer'; 3 | import { 4 | Typography, 5 | Grid, 6 | Card, 7 | CardHeader, 8 | Avatar, 9 | CardContent, 10 | Button, 11 | CardActionArea, 12 | CardMedia, 13 | CardActions, 14 | } from '@material-ui/core'; 15 | import useStyles from './style'; 16 | 17 | export default function Landing(): ReactElement { 18 | const classes = useStyles(); 19 | return ( 20 |
21 | 22 | 23 |
24 | ); 25 | } 26 | 27 | const AboutUs = () => { 28 | const afteracademyLogo = importer('@ui/header/assets/afteracademy-logo.svg'); 29 | const mindorksLogo = importer('./assets/mindorks-logo.svg'); 30 | const classes = useStyles(); 31 | return ( 32 | 33 | 34 | 35 | About Us 36 | 37 | 38 | 39 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | const InfoCard = ({ 63 | imgUrl, 64 | href, 65 | title, 66 | subtitle, 67 | description, 68 | action = 'Learn More', 69 | }: { 70 | imgUrl: string; 71 | href: string; 72 | title: string; 73 | subtitle: string; 74 | description: string; 75 | action?: string; 76 | }) => { 77 | const classes = useStyles(); 78 | return ( 79 | 80 | } 82 | title={title} 83 | subheader={subtitle} 84 | /> 85 | 86 | 87 | {description} 88 | 89 | 90 | 91 | 102 | 103 | 104 | ); 105 | }; 106 | 107 | const ReasourceCard = ({ 108 | href, 109 | imgUrl, 110 | title, 111 | description, 112 | action, 113 | }: { 114 | href: string; 115 | imgUrl: string; 116 | title: string; 117 | description: string; 118 | action: string; 119 | }) => { 120 | const classes = useStyles(); 121 | return ( 122 | 123 | 124 | 125 | 126 | 127 | {title} 128 | 129 | 130 | {description} 131 | 132 | 133 | 134 | 135 | 138 | 139 | 140 | ); 141 | }; 142 | 143 | const LearningResources = () => { 144 | const classes = useStyles(); 145 | const blog = importer('./assets/afteracademy-blog.jpg'); 146 | const youtube = importer('./assets/afteracademy-youtube.jpg'); 147 | const opensource = importer('./assets/mindorks-opensource.jpg'); 148 | const mBlog = importer('./assets/mindorks-blog.jpg'); 149 | const mMedium = importer('./assets/mindorks-medium-blog.jpg'); 150 | const mYoutube = importer('./assets/mindorks-youtube.jpg'); 151 | return ( 152 | 153 | 154 | 155 | Our Free Learning Resources 156 | 157 | 158 | 159 | 166 | 167 | 168 | 175 | 176 | 177 | 184 | 185 | 186 | 193 | 194 | 195 | 202 | 203 | 204 | 211 | 212 | 213 | 214 | 215 | ); 216 | }; 217 | -------------------------------------------------------------------------------- /src/ui/writer/writingpad/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { 5 | clearPad, 6 | removeMessage, 7 | editBlog, 8 | saveBlog, 9 | fetchBlog, 10 | submitBlog, 11 | createBlog, 12 | withdrawBlog, 13 | } from './actions'; 14 | import { useStateSelector } from '@core/reducers'; 15 | import Snackbar from '@ui/common/snackbar'; 16 | import { Prompt } from 'react-router-dom'; 17 | import Preview from '@ui/common/preview'; 18 | import { Grid, TextareaAutosize, LinearProgress } from '@material-ui/core'; 19 | import SpeedDial from '@material-ui/lab/SpeedDial'; 20 | import SpeedDialAction from '@material-ui/lab/SpeedDialAction'; 21 | import { 22 | ArrowDropDown as ArrowDropDownIcon, 23 | DoneAll as DoneAllIcon, 24 | Close as CloseIcon, 25 | Send as SendIcon, 26 | Visibility as VisibilityIcon, 27 | Save as SaveIcon, 28 | } from '@material-ui/icons'; 29 | 30 | import BlogDetailsForm from './form'; 31 | import { clearPage as clearEditorPage } from '@ui/editor/blogs/actions'; 32 | 33 | export type LocalState = { 34 | isForSubmission: boolean; 35 | isAllDataSentToServer: boolean; 36 | isBlogDetailsFormToShow: boolean; 37 | title: string; 38 | description: string; 39 | imgUrl: string; 40 | blogUrl: string; 41 | tags: Array; 42 | isWriting: boolean; 43 | isTitleError: boolean; 44 | isDescriptionError: boolean; 45 | isImgUrlError: boolean; 46 | isBlogUrlError: boolean; 47 | isTagsError: boolean; 48 | }; 49 | 50 | export default function WritingPad(): ReactElement { 51 | const classes = useStyles(); 52 | const dispatch = useDispatch(); 53 | 54 | const [preventBack, setPreventBack] = useState(false); 55 | const [showPreview, setShowPreview] = useState(false); 56 | 57 | const { hydrationBlog, data, isFetchingBlog, isSavingBlog, message } = useStateSelector( 58 | ({ writingPadState }) => writingPadState, 59 | ); 60 | 61 | const [localState, setLocalState] = useState({ 62 | isForSubmission: false, 63 | isAllDataSentToServer: false, 64 | isBlogDetailsFormToShow: false, 65 | title: '', 66 | description: '', 67 | imgUrl: '', 68 | blogUrl: '', 69 | tags: [], 70 | isWriting: false, 71 | isTitleError: false, 72 | isDescriptionError: false, 73 | isImgUrlError: false, 74 | isBlogUrlError: false, 75 | isTagsError: false, 76 | }); 77 | 78 | useEffect(() => { 79 | if (hydrationBlog?._id && !isFetchingBlog) dispatch(fetchBlog(hydrationBlog._id)); 80 | if (hydrationBlog) 81 | setLocalState({ 82 | ...localState, 83 | title: hydrationBlog.title, 84 | description: hydrationBlog.description, 85 | imgUrl: hydrationBlog.imgUrl, 86 | blogUrl: hydrationBlog.blogUrl, 87 | tags: hydrationBlog.tags, 88 | }); 89 | return () => { 90 | dispatch(clearPad.action()); 91 | }; 92 | // eslint-disable-next-line react-hooks/exhaustive-deps 93 | }, []); 94 | 95 | const handleDoneClick = () => { 96 | setLocalState({ 97 | ...localState, 98 | isBlogDetailsFormToShow: true, 99 | isForSubmission: true, 100 | }); 101 | }; 102 | 103 | const handleSaveClick = () => { 104 | if (!data?._id) { 105 | setLocalState({ ...localState, isBlogDetailsFormToShow: true }); 106 | } else { 107 | setLocalState({ ...localState, isAllDataSentToServer: true }); 108 | data?._id && 109 | dispatch( 110 | saveBlog(data._id, { 111 | text: data.draftText, 112 | title: localState.title, 113 | description: localState.description, 114 | tags: localState.tags, 115 | imgUrl: localState.imgUrl, 116 | }), 117 | ); 118 | } 119 | setPreventBack(false); 120 | }; 121 | 122 | const handleCreateClick = () => { 123 | data && 124 | dispatch( 125 | createBlog({ 126 | text: data.draftText, 127 | title: localState.title, 128 | blogUrl: localState.blogUrl, 129 | description: localState.description, 130 | tags: localState.tags, 131 | imgUrl: localState.imgUrl, 132 | }), 133 | ); 134 | }; 135 | 136 | const handleSubmitClick = () => { 137 | setLocalState({ ...localState, isBlogDetailsFormToShow: false }); 138 | data?._id && dispatch(submitBlog(data._id)); 139 | dispatch(clearEditorPage.action()); 140 | }; 141 | 142 | const handleWithdrawClick = () => { 143 | setLocalState({ ...localState, isBlogDetailsFormToShow: false }); 144 | data?._id && dispatch(withdrawBlog(data._id)); 145 | dispatch(clearEditorPage.action()); 146 | }; 147 | 148 | const renderMenu = () => { 149 | if (!data) return null; 150 | return ( 151 | 205 | ); 206 | }; 207 | 208 | return ( 209 |
210 | 'Are you sure you want to go without saving your work.'} 213 | /> 214 | {(isFetchingBlog || isSavingBlog) && } 215 | 216 | 217 | { 223 | dispatch(editBlog.action({ draftText: e.target.value })); 224 | if (!preventBack) setPreventBack(true); 225 | }} 226 | placeholder="Write something awesome today.." 227 | /> 228 | 229 | 230 | {data && setShowPreview(false)} />} 231 | 239 | {renderMenu()} 240 | {message && ( 241 | dispatch(removeMessage.action())} 245 | /> 246 | )} 247 |
248 | ); 249 | } 250 | -------------------------------------------------------------------------------- /src/ui/auth/singup.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState, ChangeEvent } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useStateSelector } from '@core/reducers'; 5 | import { validateEmail, validateUrl } from '@utils/appUtils'; 6 | import { basicSignup, removeMessage, SignupRequestBody } from './actions'; 7 | import { 8 | LinearProgress, 9 | DialogTitle, 10 | Dialog, 11 | Typography, 12 | Divider, 13 | DialogContent, 14 | Button, 15 | DialogActions, 16 | TextField, 17 | Paper, 18 | PaperProps, 19 | Box, 20 | } from '@material-ui/core'; 21 | 22 | export default function SignupDialog({ 23 | open, 24 | onClose, 25 | onLogin, 26 | }: { 27 | open: boolean; 28 | onClose: () => void; 29 | onLogin: () => void; 30 | }): ReactElement | null { 31 | const { isLoggingIn } = useStateSelector(({ authState }) => authState); 32 | 33 | const classes = useStyles(); 34 | const dispatch = useDispatch(); 35 | 36 | const defaultFormState = { 37 | name: '', 38 | email: '', 39 | password: '', 40 | profilePicUrl: '', 41 | isNameError: false, 42 | isProfilePicUrlError: false, 43 | isEmailError: false, 44 | isPasswordError: false, 45 | nameError: '', 46 | emailError: '', 47 | passwordError: '', 48 | profilePicUrlError: '', 49 | }; 50 | 51 | const [credentials, setCredentials] = useState(defaultFormState); 52 | 53 | const handleCredentialChange = (name: string) => (e: ChangeEvent) => { 54 | e.preventDefault(); // to prevent from loosing focus 55 | setCredentials({ 56 | ...credentials, 57 | isNameError: false, 58 | isProfilePicUrlError: false, 59 | isEmailError: false, 60 | isPasswordError: false, 61 | nameError: '', 62 | emailError: '', 63 | passwordError: '', 64 | profilePicUrlError: '', 65 | [name]: e.target.value, 66 | }); 67 | }; 68 | 69 | const handleBasicSignup = () => { 70 | const validations = { ...credentials }; 71 | validations.name = validations.name.trim(); 72 | validations.email = validations.email.trim(); 73 | validations.password = validations.password.trim(); 74 | validations.profilePicUrl = validations.profilePicUrl.trim(); 75 | let error = false; 76 | 77 | if (validations.name.trim().length === 0) { 78 | validations.nameError = 'Name should not be empty'; 79 | validations.isNameError = true; 80 | error = true; 81 | } 82 | 83 | if (validations.name.trim().length > 100) { 84 | validations.nameError = 'Name is too large'; 85 | validations.isNameError = true; 86 | error = true; 87 | } 88 | 89 | if (validations.email.trim().length === 0) { 90 | validations.emailError = 'Email should not be empty'; 91 | validations.isEmailError = true; 92 | error = true; 93 | } 94 | 95 | if (!validateEmail(validations.email)) { 96 | validations.emailError = 'Email is not valid'; 97 | validations.isEmailError = true; 98 | error = true; 99 | } 100 | 101 | if (validations.password.trim().length < 6) { 102 | validations.passwordError = 'Password not valid'; 103 | validations.isPasswordError = true; 104 | error = true; 105 | } 106 | 107 | if (validations.profilePicUrl.trim().length > 0 && !validateUrl(validations.profilePicUrl)) { 108 | validations.profilePicUrlError = 'URL is not valid'; 109 | validations.isProfilePicUrlError = true; 110 | error = true; 111 | } 112 | 113 | if (error) { 114 | setCredentials(validations); 115 | } else { 116 | dispatch(removeMessage.action()); 117 | const singupBody: SignupRequestBody = { 118 | email: credentials.email, 119 | password: credentials.password, 120 | name: credentials.name, 121 | }; 122 | if (validations.profilePicUrl) singupBody.profilePicUrl = validations.profilePicUrl; 123 | dispatch(basicSignup(singupBody)); 124 | } 125 | }; 126 | 127 | return ( 128 | 135 | {isLoggingIn && } 136 | 137 | {isLoggingIn ? ( 138 | 139 | Creating account please wait... 140 | 141 | ) : ( 142 |
143 | 144 | 145 | 146 | Signup Form 147 | 148 | 149 | 150 | 160 | 161 | 162 |
163 | )} 164 |
165 | 166 | 167 |
168 | 185 | 201 | 217 | 233 | 234 |
235 | 236 | 239 | 248 | 249 |
250 | ); 251 | } 252 | 253 | const PaperComponent = (props: PaperProps) => ; 254 | -------------------------------------------------------------------------------- /src/ui/editor/blogs/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from 'react'; 2 | import useStyles from './style'; 3 | import { useDispatch } from 'react-redux'; 4 | import { 5 | removeMessage, 6 | fetchBlog, 7 | publishBlog, 8 | unpublishBlog, 9 | fetchSubmittedBlogs, 10 | fetchPublishedBlogs, 11 | clearBlog, 12 | } from './actions'; 13 | import { useStateSelector } from '@core/reducers'; 14 | import Snackbar from '@ui/common/snackbar'; 15 | import NotFound from '@ui/notfound'; 16 | import ConfirmationDialog from '@ui/common/confirmation'; 17 | import { 18 | Tab, 19 | AppBar, 20 | Tabs, 21 | LinearProgress, 22 | Grid, 23 | List, 24 | ListItem, 25 | Typography, 26 | Chip, 27 | Button, 28 | Link, 29 | CardHeader, 30 | Avatar, 31 | } from '@material-ui/core'; 32 | import { BlogDetail, Author } from 'app-types'; 33 | import { convertToReadableDate } from '@utils/appUtils'; 34 | import FirstLetter from '@ui/common/firstletter'; 35 | import Preview from '@ui/common/preview'; 36 | 37 | const tabNames = ['submissions', 'published']; 38 | 39 | type DialogState = { 40 | open: boolean; 41 | blog: BlogDetail | null; 42 | }; 43 | 44 | export default function EditorBlogs(): ReactElement { 45 | const classes = useStyles(); 46 | const { 47 | data, 48 | blog, 49 | isFetchingBlog, 50 | isPublishingBlog, 51 | isUnpublishingBlog, 52 | isFetchingSubmissions, 53 | isFetchingPublished, 54 | message, 55 | } = useStateSelector((state) => state.editorBlogState); 56 | 57 | const dispatch = useDispatch(); 58 | 59 | const [currentTab, setCurrentTab] = useState(0); 60 | 61 | const [publishDialogState, setPublishDialogState] = useState({ 62 | open: false, 63 | blog: null, 64 | }); 65 | const [unpublishDialogState, setUnpublishDialogState] = useState({ 66 | open: false, 67 | blog: null, 68 | }); 69 | 70 | useEffect(() => { 71 | if (!data) { 72 | !isFetchingSubmissions && dispatch(fetchSubmittedBlogs()); 73 | !isFetchingPublished && dispatch(fetchPublishedBlogs()); 74 | } 75 | // eslint-disable-next-line react-hooks/exhaustive-deps 76 | }, []); 77 | 78 | const getBlogsForTab = () => { 79 | if (!data) return null; 80 | let blogs = null; 81 | if (tabNames[currentTab] === 'submissions') blogs = data['submissions']; 82 | else if (tabNames[currentTab] === 'published') blogs = data['published']; 83 | return blogs; 84 | }; 85 | 86 | const getContent = () => { 87 | if (!data) return null; 88 | const blogs = getBlogsForTab(); 89 | if (!blogs) return ; 90 | const content = blogs.map((blog) => ( 91 | 92 | 93 |
94 | 95 | {blog.title} 96 | 97 | {blog.isPublished === true && (blog.isDraft === true || blog.isSubmitted === true) && ( 98 | 105 | )} 106 | 107 | {blog.description} 108 | 109 | {blog.tags.map((tag) => ( 110 | 117 | ))} 118 |
119 | {blog.imgUrl && ( 120 | 121 | 127 | 128 | )} 129 | {blog.blogUrl && } 130 |
131 | {blog.author && } 132 |
133 | 142 | {blog.isSubmitted === true && blog.isPublished === false && ( 143 | 152 | )} 153 | {blog.isSubmitted === true && blog.isPublished === true && ( 154 | 163 | )} 164 | {blog.isPublished === true && ( 165 | 174 | )} 175 |
176 |
177 |
178 |
179 | )); 180 | if (content.length === 0) return ; 181 | return content; 182 | }; 183 | 184 | return ( 185 |
186 | 187 | setCurrentTab(newValue)} 190 | indicatorColor="primary" 191 | textColor="primary" 192 | variant="fullWidth" 193 | scrollButtons="auto" 194 | aria-label="blogs tab" 195 | > 196 | {tabNames.map((name) => ( 197 | 204 | ))} 205 | 206 | {(isFetchingSubmissions || 207 | isFetchingPublished || 208 | isFetchingBlog || 209 | isPublishingBlog || 210 | isUnpublishingBlog) && } 211 | 212 | 213 | 214 | 215 | {getContent()} 216 | 217 | 218 | 219 | {blog && dispatch(clearBlog.action())} />} 220 | { 225 | if (publishDialogState.blog) dispatch(publishBlog(publishDialogState.blog)); 226 | setPublishDialogState({ open: false, blog: null }); 227 | }} 228 | onNegativeAction={() => setPublishDialogState({ open: false, blog: null })} 229 | positionText="Publish Now" 230 | negativeText="Cancel" 231 | /> 232 | { 237 | if (unpublishDialogState.blog) dispatch(unpublishBlog(unpublishDialogState.blog)); 238 | setUnpublishDialogState({ open: false, blog: null }); 239 | }} 240 | onNegativeAction={() => setUnpublishDialogState({ open: false, blog: null })} 241 | positionText="Unpublish Now" 242 | negativeText="Cancel" 243 | /> 244 | {message && ( 245 | dispatch(removeMessage.action())} 249 | /> 250 | )} 251 |
252 | ); 253 | } 254 | 255 | const AuthorView = ({ author, date }: { author: Author; date?: string }) => { 256 | const classes = useStyles(); 257 | return ( 258 |
259 | {author.profilePicUrl ? ( 260 | } 262 | title={author.name} 263 | subheader={date ? convertToReadableDate(date) : ''} 264 | /> 265 | ) : ( 266 | } 268 | title={author.name} 269 | subheader={date ? convertToReadableDate(date) : ''} 270 | /> 271 | )} 272 |
273 | ); 274 | }; 275 | --------------------------------------------------------------------------------