├── babel.config.js ├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── react-app-env.d.ts ├── components │ ├── NavigationItem │ │ ├── NavigationItem.css │ │ ├── NavigationItem.tsx │ │ └── SubMenu │ │ │ └── SubMenu.js │ ├── Post │ │ ├── Post.css │ │ ├── Post.test.tsx │ │ ├── Post.tsx │ │ └── SinglePost.tsx │ ├── WPPage │ │ └── index.tsx │ └── Auth │ │ ├── Signout.js │ │ ├── SignIn.js │ │ └── SignUp.js ├── setupTests.js ├── axios-wp.ts ├── store │ ├── actions │ │ ├── index.actions.ts │ │ ├── pages.actions.ts │ │ ├── users.actions.ts │ │ └── posts.actions.ts │ ├── store.ts │ └── slices │ │ ├── pages.slice.ts │ │ ├── posts.slice.ts │ │ └── users.slice.ts ├── index.css ├── App.css ├── commons │ └── constants.ts ├── types │ ├── pages.types.ts │ ├── posts.types.ts │ ├── users.types.ts │ └── wptypes.ts ├── index.tsx ├── containers │ ├── Navigation │ │ └── Navigation.tsx │ ├── Dashboard │ │ └── Dashboard.js │ ├── Blog │ │ └── Blog.tsx │ └── Auth │ │ └── Auth.js ├── hooks │ ├── useWPPages │ │ └── index.tsx │ └── useNavMenu │ │ └── index.tsx ├── logo.svg └── App.tsx ├── .github └── workflows │ └── test.yaml ├── .gitignore ├── test-preprocessor.js ├── craco.config.js ├── README.md ├── tsconfig.json ├── CHANGELOG.md ├── .eslintrc.js ├── .snyk └── package.json /babel.config.js: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["@babel/preset-env", "@babel/preset-react"]; 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kvnam/reactpress/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare module "*"; -------------------------------------------------------------------------------- /src/components/NavigationItem/NavigationItem.css: -------------------------------------------------------------------------------- 1 | .nav-item:not(:last-child) { 2 | margin-right: 2rem; 3 | } 4 | 5 | .submenu { 6 | flex-direction: row-reverse; 7 | } 8 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require("enzyme"); 2 | const Adapter = require("enzyme-adapter-react-16"); 3 | 4 | Enzyme.configure({ 5 | adapter: new Adapter(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/axios-wp.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const axiosInstance = axios.create({ 4 | baseURL: process.env.REACT_APP_WORDPRESS_URL, 5 | }); 6 | 7 | export default axiosInstance; 8 | -------------------------------------------------------------------------------- /src/components/Post/Post.css: -------------------------------------------------------------------------------- 1 | .excerpt-img { 2 | width: 100%; 3 | } 4 | 5 | .post-img { 6 | width: 100%; 7 | margin: 1rem 0; 8 | } 9 | 10 | .cat-tags { 11 | display: inline; 12 | } 13 | .cat-tags:not(:first-child) { 14 | margin-left: 1rem; 15 | } 16 | -------------------------------------------------------------------------------- /src/store/actions/index.actions.ts: -------------------------------------------------------------------------------- 1 | export { loadAllPosts, loadSinglePost, searchAllPosts } from "./posts.actions"; 2 | 3 | export { userSignin, userSignup, validateToken, userSignout } from "./users.actions"; 4 | 5 | export { getAllPages } from "./pages.actions"; 6 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 2 | import { AppDispatch, RootState } from "@/index"; 3 | 4 | export const useRPDispatch = () => useDispatch(); 5 | export const useRPSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test-reactpress 2 | on: [pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Test ReactPress 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | - run: yarn install --frozen-lockfile 13 | - run: yarn test 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | reactpress.todo -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", 5 | "Droid Sans", "Helvetica Neue", sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/NavigationItem/NavigationItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import { NavItem } from "reactstrap"; 4 | 5 | type NavItemProps = { 6 | link: string; 7 | linkName: string; 8 | }; 9 | 10 | const navigationItem = (props: NavItemProps) => { 11 | const { link, linkName } = props; 12 | return ( 13 | 14 | {linkName} 15 | 16 | ); 17 | }; 18 | 19 | export default navigationItem; 20 | -------------------------------------------------------------------------------- /test-preprocessor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Transpiles TypeScript to JavaScript code. 3 | * 4 | * @link https://github.com/facebook/jest/blob/master/examples/typescript/preprocessor.js 5 | * @copyright 2004-present Facebook. All Rights Reserved. 6 | */ 7 | const tsc = require("typescript"); 8 | const tsConfig = require("./tsconfig.json"); 9 | 10 | module.exports = { 11 | process(src, path) { 12 | if (path.endsWith(".ts") || path.endsWith(".tsx")) { 13 | return tsc.transpile(src, tsConfig.compilerOptions, path, []); 14 | } 15 | return src; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: left; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | } 9 | 10 | .App-header { 11 | background-color: #282c34; 12 | min-height: 100vh; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | justify-content: center; 17 | font-size: calc(10px + 2vmin); 18 | color: white; 19 | } 20 | 21 | .App-link { 22 | color: #61dafb; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/NavigationItem/SubMenu/SubMenu.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { NavLink } from "react-router-dom"; 3 | import { Nav, NavItem } from "reactstrap"; 4 | 5 | const sidebar = (props) => { 6 | 7 | return( 8 | 19 | ); 20 | }; 21 | 22 | export default sidebar; -------------------------------------------------------------------------------- /src/commons/constants.ts: -------------------------------------------------------------------------------- 1 | import { NavItemType } from "../types/pages.types"; 2 | 3 | export const baseNavItems: NavItemType[] = [ 4 | { 5 | link: "/", 6 | linkName: "BLOG", 7 | isVisible: "all", 8 | component: "Blog", 9 | }, 10 | { 11 | link: "/auth/signin", 12 | linkName: "LOG IN", 13 | isVisible: "noauth", 14 | }, 15 | { 16 | link: "/auth/signup", 17 | linkName: "SIGN UP", 18 | isVisible: "noauth", 19 | }, 20 | { 21 | link: "/post", 22 | linkName: "Post", 23 | isVisible: "all", 24 | component: "SinglePost", 25 | }, 26 | ]; 27 | 28 | export const APP_NAME = "ReatPress"; 29 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | typescript: { 5 | enableTypeChecking: true, 6 | }, 7 | babel: { 8 | plugins: ["babel-plugin-styled-components"], 9 | }, 10 | webpack: { 11 | alias: { 12 | "@": path.resolve(__dirname, "src/"), 13 | "@components": path.resolve(__dirname, "src/components"), 14 | "@containers": path.resolve(__dirname, "src/containers"), 15 | "@commons": path.resolve(__dirname, "src/commons"), 16 | "@hooks": path.resolve(__dirname, "src/hooks"), 17 | "@store": path.resolve(__dirname, "src/store"), 18 | "@rptypes": path.resolve(__dirname, "src/types"), 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/Post/Post.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow, ShallowWrapper } from "enzyme"; 3 | import { Col } from "reactstrap"; 4 | 5 | import Post from "./Post"; 6 | 7 | describe("", () => { 8 | test("Render 4 Cols and title - Test Post", () => { 9 | const wrapper = shallow( 10 | {}} />, 11 | ); 12 | const colElements: ShallowWrapper = wrapper.find(Col); 13 | const titleCol = colElements.first().props().children[0]; 14 | expect(colElements).toHaveLength(4); 15 | expect(titleCol).toEqual("Test Post"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/WPPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | // TODO: Add classname and other props from WP meta tags 4 | // Set up each page 5 | 6 | type WPPageProps = { 7 | children?: React.ReactNode; 8 | pageClassName?: string; 9 | }; 10 | 11 | const WPPage: React.FC = (props: WPPageProps) => { 12 | const { children, pageClassName } = props; 13 | return
{children}
; 14 | }; 15 | 16 | WPPage.defaultProps = { 17 | children: null, 18 | pageClassName: "", 19 | }; 20 | 21 | WPPage.propTypes = { 22 | children: PropTypes.node, 23 | pageClassName: PropTypes.string, 24 | }; 25 | 26 | export default WPPage; 27 | -------------------------------------------------------------------------------- /src/types/pages.types.ts: -------------------------------------------------------------------------------- 1 | import type { WPPage } from "./wptypes"; 2 | 3 | export const GET_ALL_PAGES = "GET_ALL_PAGES"; 4 | 5 | export type NavItemType = { 6 | link: string; 7 | linkName: string; 8 | isVisible: string; 9 | component?: string; 10 | }; 11 | 12 | export type AllPagesType = { 13 | pagesLoading: boolean; 14 | pages?: WPPage[] | any | null; 15 | error?: Error | any | null; 16 | }; 17 | 18 | export type RPPage = { 19 | [key: string]: WPPage; 20 | }; 21 | 22 | export type RPPagesHookType = { 23 | pagesLoading?: boolean; 24 | pages: RPPage; 25 | }; 26 | 27 | export type GETALLPAGESACTION = { 28 | pagesLoading?: boolean; 29 | pages?: WPPage[] | any | null; 30 | error?: Error | any | null; 31 | }; 32 | -------------------------------------------------------------------------------- /src/store/actions/pages.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import axios from "@/axios-wp"; 3 | 4 | import type { WPPage } from "@rptypes/wptypes"; 5 | 6 | export const getAllPages = createAsyncThunk("pages/getAllPages", async () => { 7 | const pagesRes = await axios.get("wp/v2/pages"); 8 | if (pagesRes) { 9 | const finalPages: WPPage[] = []; 10 | pagesRes.data.forEach((page: WPPage) => { 11 | // TODO: Add processing of page 12 | finalPages.push(page); 13 | }); 14 | return { pages: finalPages, pagesLoading: false }; 15 | } 16 | return { pages: [], pagesLoading: false }; 17 | }); 18 | 19 | export const getPage = () => { 20 | return { type: "GET_PAGE", page: {}, pageLoading: false }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/store/slices/pages.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import type { RootState } from "@/index"; 4 | import { GETALLPAGESACTION } from "@rptypes/pages.types"; 5 | import { getAllPages } from "@store/actions/index.actions"; 6 | 7 | const initialState = { 8 | pages: null, 9 | pagesLoading: false, 10 | }; 11 | 12 | export const pagesSlice = createSlice({ 13 | name: "pages", 14 | initialState, 15 | reducers: {}, 16 | extraReducers: (builder) => { 17 | builder.addCase(getAllPages.fulfilled, (state, action: PayloadAction) => { 18 | state.pages = action.payload.pages; 19 | state.pagesLoading = false; 20 | }); 21 | }, 22 | }); 23 | 24 | export default pagesSlice.reducer; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactPress 2 | 3 | ## Under development 4 | 5 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/926e0227b23a428d86a2b22afce124cf)](https://www.codacy.com/gh/kvnam/reactpress/dashboard?utm_source=github.com&utm_medium=referral&utm_content=kvnam/reactpress&utm_campaign=Badge_Grade) 6 | 7 | Wordpress API delivered through a React application. 8 | 9 | The app has the following objectives 10 | 11 | **All Users** 12 | 13 | - List all posts 14 | - Search for specific posts 15 | - Filter posts by 16 | - Categories 17 | - Tags 18 | - Date 19 | - Display pages from Wordpress 20 | 21 | **Registered Users** 22 | 23 | - Sign Up / Sign In - Authentication added with JWT-Authentication plugin 24 | - Create a post 25 | - Retrieve self-authored posts 26 | - Delete self-authored posts 27 | 28 | License 29 | This project is licensed under the MIT License - see the LICENSE.md file for details 30 | -------------------------------------------------------------------------------- /src/types/posts.types.ts: -------------------------------------------------------------------------------- 1 | import { WPPost, WPCategory } from "./wptypes"; 2 | 3 | export const LOADING_ALL_POSTS = "LOADING_ALL_POSTS"; 4 | export const GET_ALL_POSTS = "GET_ALL_POSTS"; 5 | export const SINGLE_POST_ACTION = "SINGLE_POST_ACTION"; 6 | export const SEARCH_POSTS_ACTION = "SEARCH_POSTS_ACTION"; 7 | 8 | export interface RPPost extends WPPost { 9 | medialink?: string; 10 | categoryTags?: WPCategory[]; 11 | } 12 | 13 | export type LoadingPostsAction = { 14 | type: typeof LOADING_ALL_POSTS; 15 | postsLoading: boolean; 16 | }; 17 | 18 | export type GetAllPostsAction = { 19 | posts: [WPPost] | any | null; 20 | postsLoading?: boolean; 21 | error?: Error | any | null; 22 | }; 23 | 24 | export type GetSinglePostAction = { 25 | post?: RPPost | any | null; 26 | error?: Error | any | null; 27 | postsLoading?: boolean; 28 | }; 29 | 30 | export type SearchPostsAction = { 31 | posts?: [WPPost] | any | null; 32 | postsLoading?: boolean; 33 | error?: Error | any | null; 34 | }; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": "./src", 23 | "paths": { 24 | "@/*": ["./*"], 25 | "@components/*": ["components/*"], 26 | "@containers/*": ["containers/*"], 27 | "@commons/*": ["commons/*"], 28 | "@hook/*s": ["hooks/*"], 29 | "@store/*": ["store/*"], 30 | "@rptypes/*": ["types/*"] 31 | } 32 | }, 33 | "exclude": [ 34 | "src/**/*.js" 35 | ], 36 | "include": [ "src" ] 37 | } 38 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "bootstrap/dist/css/bootstrap.min.css"; 4 | 5 | import { configureStore } from "@reduxjs/toolkit"; 6 | import { Provider } from "react-redux"; 7 | import { BrowserRouter } from "react-router-dom"; 8 | import UserReducer from "./store/slices/users.slice"; 9 | import PostReducer from "./store/slices/posts.slice"; 10 | import PagesReducer from "./store/slices/pages.slice"; 11 | import "./index.css"; 12 | import App from "./App"; 13 | 14 | const appStore = configureStore({ 15 | reducer: { 16 | // users: UserReducer, 17 | posts: PostReducer, 18 | pages: PagesReducer, 19 | }, 20 | }); 21 | export type RootState = ReturnType; 22 | export type AppDispatch = typeof appStore.dispatch; 23 | 24 | const app = ( 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | ReactDOM.render(app, document.getElementById("root")); 33 | -------------------------------------------------------------------------------- /src/containers/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { Navbar, NavbarBrand, Nav } from "reactstrap"; 3 | 4 | import NavigationItem from "../../components/NavigationItem/NavigationItem"; 5 | import useNavMenu from "../../hooks/useNavMenu/index"; 6 | 7 | import "../../components/NavigationItem/NavigationItem.css"; 8 | 9 | // TODO: Add authentication 10 | 11 | const Navigation: React.FC = () => { 12 | const navMenuList = useNavMenu(); 13 | 14 | const navList = useMemo(() => { 15 | return navMenuList 16 | .map((item) => { 17 | return ; 18 | }) 19 | .filter((v) => !!v); 20 | }, [navMenuList]); 21 | 22 | return ( 23 | <> 24 | 25 | REACTPRESS 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default Navigation; 33 | -------------------------------------------------------------------------------- /src/components/Auth/Signout.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | //import { Redirect } from "react-router-dom"; 4 | import * as actionTypes from "../../store/actions/index.actions"; 5 | 6 | class Signout extends Component{ 7 | componentDidMount(){ 8 | if(this.props.token){ 9 | //Dispatch User Signout action 10 | this.props.logoutUser(this.props.token); 11 | } 12 | } 13 | 14 | render(){ 15 | let authRedirect = null; 16 | if(!this.props.token || this.props.token === ""){ 17 | // authRedirect = ; 18 | } 19 | return( 20 |
21 | {authRedirect} 22 |
23 | ); 24 | } 25 | } 26 | 27 | const mapStateToProps = (state) => { 28 | return { 29 | token: state.usersRed.token 30 | }; 31 | }; 32 | 33 | const mapDispatchStateToProps = (dispatch) => { 34 | return { 35 | logoutUser: (token) => {dispatch(actionTypes.userSignout(token))} 36 | }; 37 | }; 38 | 39 | export default connect(mapStateToProps, mapDispatchStateToProps)(Signout); -------------------------------------------------------------------------------- /src/containers/Dashboard/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Row, Col } from "reactstrap"; 3 | import { Switch, Route } from "react-router-dom"; 4 | import { connect } from "react-redux"; 5 | 6 | class Dashboard extends Component{ 7 | 8 | render(){ 9 | let authRedirect = null; 10 | if(this.props.token === null || this.props.token === ""){ 11 | // authRedirect = 12 | } 13 | return( 14 | 15 | {authRedirect} 16 | 17 | 18 |
CREATE POST CONTENT
}/> 19 |
DASHBOARD CONTENT
}/> 20 |
21 | 22 |
23 | ); 24 | } 25 | } 26 | 27 | const mapStateToProps = (state) => { 28 | return { 29 | token: state.usersRed.token 30 | }; 31 | } 32 | 33 | export default connect(mapStateToProps)(Dashboard); -------------------------------------------------------------------------------- /src/components/Post/Post.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Row, Col, Button } from "reactstrap"; 3 | import ReactHtmlParser from "react-html-parser"; 4 | 5 | import "./Post.css"; 6 | 7 | type PostProps = { 8 | title: string; 9 | excerpt: string; 10 | medialink: string; 11 | postId: number; 12 | onReadMore: (postId: number) => void; 13 | }; 14 | 15 | const Post: React.FC = (props: PostProps) => { 16 | const { title, excerpt, medialink, postId, onReadMore } = props; 17 | const titleHtml = ReactHtmlParser(title); 18 | const excerptHtml = ReactHtmlParser(excerpt); 19 | 20 | return ( 21 | 22 | 23 | {titleHtml} 24 | 25 | 26 | {postId.toString()} 27 | 28 | 29 | {excerptHtml} 30 | 31 | 32 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Post; 41 | -------------------------------------------------------------------------------- /src/types/users.types.ts: -------------------------------------------------------------------------------- 1 | export const VALIDATE_TOKEN_ACTION = "VALIDATE_TOKEN_ACTION"; 2 | export const USER_SIGNUP_ACTION = "USER_SIGNUP_ACTION"; 3 | export const USER_SIGNIN_ACTION = "USER_SIGNIN_ACTION"; 4 | export const USER_SIGNOUT = "USER_SIGNOUT"; 5 | export const USER_SIGNOUT_ACTION = "USER_SIGNOUT_ACTION"; 6 | export const USER_LOADING_ACTION = "USER_LOADING_ACTION"; 7 | 8 | export type User = { 9 | username?: string; 10 | email?: string; 11 | displayname?: string; 12 | }; 13 | 14 | export type LoadingUsersAction = { 15 | type: typeof USER_LOADING_ACTION; 16 | userLoading: boolean; 17 | }; 18 | 19 | export type UserSignUpAction = { 20 | userLoading: boolean; 21 | user?: User; 22 | redirectURL?: string; 23 | error?: Error | any | null; 24 | }; 25 | 26 | export type UserSignInAction = { 27 | userLoading: boolean; 28 | user?: User; 29 | token?: string; 30 | error?: Error | any | null; 31 | }; 32 | 33 | export type ValidateUserAction = { 34 | token?: string; 35 | email?: string; 36 | redirectTo?: string; 37 | error?: Error | any | null; 38 | }; 39 | 40 | export type UserSignoutAction = { 41 | status: boolean; 42 | error?: Error | any | null; 43 | }; 44 | -------------------------------------------------------------------------------- /src/hooks/useWPPages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useRPDispatch, useRPSelector } from "@store/store"; 3 | 4 | import { WPPage } from "@rptypes/wptypes"; 5 | import { AllPagesType } from "@rptypes/pages.types"; 6 | import { getAllPages } from "@store/actions/pages.actions"; 7 | import { RootState } from "@/index"; 8 | 9 | type RPPage = { 10 | [key: string]: WPPage; 11 | }; 12 | 13 | const useWPPages = () => { 14 | const pagesState: AllPagesType = useRPSelector((state: RootState) => state.pages); 15 | const [pages, setPages] = useState({}); 16 | 17 | const dispatch = useRPDispatch(); 18 | 19 | useEffect(() => { 20 | dispatch(getAllPages()); 21 | }, []); 22 | 23 | useEffect(() => { 24 | if (!pagesState?.pages?.length || (pagesState?.pages?.length && Object.keys(pages).length)) { 25 | return; 26 | } 27 | const { pages: pagesData = [] } = pagesState; 28 | const updatedPages: RPPage = {}; 29 | 30 | pagesData.forEach((page: WPPage) => { 31 | updatedPages[page.slug] = { ...page }; 32 | }); 33 | setPages(updatedPages); 34 | }, [pagesState]); 35 | 36 | return { 37 | pagesLoading: pagesState.pagesLoading, 38 | pages, 39 | }; 40 | }; 41 | 42 | export default useWPPages; 43 | -------------------------------------------------------------------------------- /src/components/Auth/SignIn.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Col, Form, InputGroup, FormGroup, Input, Button, InputGroupAddon, InputGroupText } from "reactstrap"; 3 | 4 | const signIn = (props) => { 5 | return ( 6 | 7 |

Sign In!

8 |
9 | 10 | 11 | Username 12 | 13 | props.inputChanged(event, "username", "signin")}/> 14 | 15 | 16 | 17 | Password 18 | 19 | props.inputChanged(event, "password", "signin")}/> 20 | 21 | 22 | 23 | 24 |
25 | 26 | ) 27 | }; 28 | 29 | export default signIn; -------------------------------------------------------------------------------- /src/store/slices/posts.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import type { RootState } from "@/index"; 4 | import type { 5 | GetAllPostsAction, 6 | LoadingPostsAction, 7 | GetSinglePostAction, 8 | SearchPostsAction, 9 | } from "@rptypes/posts.types"; 10 | import { loadAllPosts, searchAllPosts, loadSinglePost } from "@store/actions/index.actions"; 11 | 12 | const initialState = { 13 | posts: null, 14 | error: null, 15 | post: null, 16 | totalPages: 0, 17 | totalPosts: 0, 18 | perPage: 5, 19 | postsLoading: false, 20 | }; 21 | 22 | export const postsSlice = createSlice({ 23 | name: "posts", 24 | initialState, 25 | reducers: {}, 26 | extraReducers: (builder) => { 27 | builder.addCase(loadAllPosts.fulfilled, (state, action: PayloadAction) => { 28 | state.posts = action.payload.posts; 29 | state.postsLoading = false; 30 | }); 31 | builder.addCase(searchAllPosts.fulfilled, (state, action: PayloadAction) => { 32 | state.posts = action.payload.posts; 33 | state.postsLoading = false; 34 | }); 35 | builder.addCase(loadSinglePost.fulfilled, (state, action: PayloadAction) => { 36 | state.post = action.payload.post; 37 | state.postsLoading = false; 38 | }); 39 | }, 40 | }); 41 | 42 | export default postsSlice.reducer; 43 | -------------------------------------------------------------------------------- /src/hooks/useNavMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | import useWPPages from "../useWPPages"; 4 | import { AllPagesType, NavItemType } from "../../types/pages.types"; 5 | import { WPPage } from "../../types/wptypes"; 6 | import { baseNavItems } from "../../commons/constants"; 7 | 8 | const useNavMenu = () => { 9 | const wpPages: AllPagesType = useWPPages(); 10 | const [navItems, setNavItems] = useState([...baseNavItems]); 11 | 12 | useEffect(() => { 13 | if (!wpPages || navItems.length === baseNavItems.length + Object.keys(wpPages?.pages || {}).length) { 14 | return; 15 | } 16 | const { pages = {} } = wpPages; 17 | const pagesSlugs = Object.keys(pages); 18 | const updatedNavItems: Array = [...navItems]; 19 | const slugsInNavItems: Array = updatedNavItems.map((item) => item.link); 20 | pagesSlugs.forEach((pageSlug: string) => { 21 | const page = pages[pageSlug]; 22 | if (!slugsInNavItems.includes(`/${page.slug}`)) { 23 | updatedNavItems.push({ 24 | link: `/${page.slug}`, 25 | linkName: page.title.rendered, 26 | isVisible: "noauth", 27 | }); 28 | slugsInNavItems.push(`/${page.slug}`); 29 | } 30 | }); 31 | setNavItems(updatedNavItems); 32 | }, [wpPages]); 33 | 34 | return navItems; 35 | }; 36 | 37 | export default useNavMenu; 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | Date: 18/08/21 (Version 2.0.0) 4 | 5 | - Moved to Redux toolkit 6 | - Feature slices added for User, Post, Pages 7 | - Version upgrades to the following packages 8 | 9 | - Dependencies (Major version upgrades) 10 | - `@craco/craco` - "^5.6.4" to "^6.0.0" 11 | - `bootstrap` - "^4.5.0" to "^5.0.0" 12 | - `react`: "^16.13.0" to "^17.0.0" 13 | - `react-dom`: "^16.13.0" to "^17.0.0" 14 | - `typescript`: "^3.9.6" to "^4.0.0" 15 | 16 | - Dev Dependencies 17 | - `eslint-config-airbnb-typescript` 18 | - `eslint-config-prettier` 19 | - `@types/jest` 20 | - `@types/node` 21 | - `@types/react` 22 | - `@types/react-dom` 23 | - `@types/react-router-dom` 24 | - `@typescript-eslint/eslint-plugin` 25 | - `@typescript-eslint/parser` 26 | 27 | - Added aliases 28 | 29 | Date: 04/08/2021 30 | 31 | - Project under active development, please pull code and run at your own risk. 32 | - Started move to Redux hooks 33 | - Added Feature to pull pages from Wordpress API and display in ReactPress (pending) 34 | 35 | Date: 09/2020 36 | 37 | - Working on moving to Typescript, adding tests, upgrading UI to allow more customization 38 | 39 | Date: 27/11/2018 40 | 41 | - Changed to [simple-jwt-authentication by Jonathan](https://github.com/jonathan-dejong/simple-jwt-authentication/wiki/Documentation) 42 | - Added Login and Log out for existing user 43 | 44 | Date: 25/11/2018 45 | 46 | - Added login with [jwt-authentication-plugin by Tmeister](https://github.com/Tmeister/wp-api-jwt-auth) 47 | 48 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 49 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["airbnb-typescript", "plugin:@typescript-eslint/recommended", "prettier", "plugin:prettier/recommended"], 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | }, 8 | rules: { 9 | "prettier/prettier": [ 10 | "warn", 11 | { 12 | trailingComma: "all", 13 | tabWidth: 2, 14 | semi: true, 15 | singleQuote: false, 16 | bracketSpacing: true, 17 | eslintIntegration: true, 18 | printWidth: 120, 19 | endOfLine: "auto", 20 | }, 21 | ], 22 | "no-underscore-dangle": "off", 23 | "spaced-comment": "warn", 24 | "prefer-const": "warn", 25 | "prefer-template": "warn", 26 | "one-var": "warn", 27 | "@typescript-eslint/camelcase": "off", 28 | "@typescript-eslint/no-use-before-define": "off", 29 | camelcase: "off", 30 | "import/extensions": [ 31 | "error", 32 | "ignorePackages", 33 | { 34 | js: "never", 35 | jsx: "never", 36 | ts: "never", 37 | tsx: "never", 38 | }, 39 | ], 40 | "@typescript-eslint/ban-types": [ 41 | "error", 42 | { 43 | types: { 44 | String: false, 45 | Boolean: false, 46 | Number: false, 47 | Symbol: false, 48 | "{}": false, 49 | Object: false, 50 | object: false, 51 | Function: false, 52 | }, 53 | extendDefaults: true, 54 | }, 55 | ], 56 | "no-param-reassign": [ 57 | "error", 58 | { 59 | props: true, 60 | ignorePropertyModificationsFor: ["state"], 61 | }, 62 | ], 63 | }, 64 | overrides: [ 65 | { 66 | files: ["*.ts", "*.tsx"], 67 | parserOptions: { 68 | project: ["./tsconfig.json"], 69 | }, 70 | }, 71 | ], 72 | parser: "@typescript-eslint/parser", 73 | ignorePatterns: [".eslintrc.js"], 74 | }; 75 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.19.0 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - snyk > graphlib > lodash: 8 | patched: '2021-05-24T03:59:25.372Z' 9 | - snyk > @snyk/dep-graph > graphlib > lodash: 10 | patched: '2021-05-24T03:59:25.372Z' 11 | - snyk > snyk-nodejs-lockfile-parser > graphlib > lodash: 12 | patched: '2021-05-24T03:59:25.372Z' 13 | - snyk > snyk-go-plugin > graphlib > lodash: 14 | patched: '2021-05-24T03:59:25.372Z' 15 | - snyk > @snyk/snyk-cocoapods-plugin > @snyk/dep-graph > graphlib > lodash: 16 | patched: '2021-05-24T03:59:25.372Z' 17 | - snyk > snyk-cpp-plugin > @snyk/dep-graph > graphlib > lodash: 18 | patched: '2021-05-24T03:59:25.372Z' 19 | - snyk > snyk-docker-plugin > @snyk/dep-graph > graphlib > lodash: 20 | patched: '2021-05-24T03:59:25.372Z' 21 | - snyk > snyk-go-plugin > @snyk/dep-graph > graphlib > lodash: 22 | patched: '2021-05-24T03:59:25.372Z' 23 | - snyk > snyk-gradle-plugin > @snyk/java-call-graph-builder > graphlib > lodash: 24 | patched: '2021-05-24T03:59:25.372Z' 25 | - snyk > snyk-docker-plugin > snyk-nodejs-lockfile-parser > graphlib > lodash: 26 | patched: '2021-05-24T03:59:25.372Z' 27 | - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > graphlib > lodash: 28 | patched: '2021-05-24T03:59:25.372Z' 29 | - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/dep-graph > graphlib > lodash: 30 | patched: '2021-05-24T03:59:25.372Z' 31 | - snyk > snyk-gradle-plugin > @snyk/cli-interface > @snyk/dep-graph > graphlib > lodash: 32 | patched: '2021-05-24T03:59:25.372Z' 33 | - snyk > snyk-mvn-plugin > @snyk/cli-interface > @snyk/dep-graph > graphlib > lodash: 34 | patched: '2021-05-24T03:59:25.372Z' 35 | - snyk > snyk-python-plugin > snyk-poetry-lockfile-parser > @snyk/dep-graph > graphlib > lodash: 36 | patched: '2021-05-24T03:59:25.372Z' 37 | -------------------------------------------------------------------------------- /src/components/Auth/SignUp.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Col, Form, FormGroup, InputGroup, InputGroupAddon, InputGroupText, Input, Button } from "reactstrap"; 3 | 4 | const signUp = (props) => { 5 | return ( 6 | 7 |

Sign up here!

8 |
9 | 10 | 11 | Email 12 | 13 | props.inputChanged(event, 'email', 'signin')}/> 16 | 17 | 18 | 19 | Password 20 | 21 | props.inputChanged(event, 'password', 'signin')} /> 24 | 25 | 26 | 27 | First name 28 | 29 | props.inputChanged(event, 'first_name', 'signin')}/> 32 | 33 | 34 | 35 | Last name 36 | 37 | props.inputChanged(event, 'last_name', 'signin')}/> 40 | 41 | 42 | 43 | 44 | 45 |
46 | 47 | ); 48 | }; 49 | 50 | export default signUp; -------------------------------------------------------------------------------- /src/store/slices/users.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import { userSignup, userSignin, validateToken, userSignout } from "@store/actions/users.actions"; 4 | import { UserSignInAction, ValidateUserAction, UserSignoutAction, UserSignUpAction } from "@rptypes/users.types"; 5 | 6 | const initialState = { 7 | username: "", 8 | email: "", 9 | displayname: "", 10 | token: "", 11 | error: null, 12 | isLoading: false, 13 | redirectURL: "/", 14 | userLoading: false, 15 | }; 16 | 17 | export const usersSlice = createSlice({ 18 | initialState, 19 | name: "users", 20 | reducers: {}, 21 | extraReducers: (builder) => { 22 | builder.addCase(userSignup.fulfilled, (state, action: PayloadAction) => { 23 | const { user = {} } = action.payload; 24 | state.userLoading = action.payload.userLoading; 25 | state.username = user.username || ""; 26 | state.email = user.email || ""; 27 | state.displayname = user.displayname || ""; 28 | state.redirectURL = "/signin?user=new"; 29 | state.error = action.payload.error; 30 | }); 31 | 32 | builder.addCase(userSignin.fulfilled, (state, action: PayloadAction) => { 33 | const { user = {} } = action.payload; 34 | state.userLoading = false; 35 | state.username = user.username || ""; 36 | state.email = user.email || ""; 37 | state.displayname = user.displayname || ""; 38 | state.token = action.payload.token || ""; 39 | state.error = action.payload.error; 40 | }); 41 | 42 | builder.addCase(validateToken.fulfilled, (state, action: PayloadAction) => { 43 | state.token = action.payload.token || ""; 44 | state.email = action.payload.email || ""; 45 | state.redirectURL = action.payload.redirectTo || ""; 46 | state.error = action.payload.error; 47 | }); 48 | 49 | builder.addCase(userSignout.fulfilled, (state, action: PayloadAction) => { 50 | if (action.payload.status) { 51 | state.token = ""; 52 | state.email = ""; 53 | state.redirectURL = ""; 54 | } else { 55 | state.error = action.payload.error; 56 | } 57 | }); 58 | }, 59 | }); 60 | 61 | export default usersSlice.reducer; 62 | -------------------------------------------------------------------------------- /src/components/Post/SinglePost.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useEffect, useState } from "react"; 2 | import { Container, Row, Col, Badge } from "reactstrap"; 3 | import reactHtmlParser from "react-html-parser"; 4 | import { useLocation } from "react-router-dom"; 5 | 6 | import { RootState } from "@/index"; 7 | import { useRPSelector, useRPDispatch } from "@store/store"; 8 | import * as actionMethods from "@store/actions/index.actions"; 9 | import type { RPPost } from "@rptypes/posts.types"; 10 | 11 | const SinglePost = () => { 12 | const postsState = useRPSelector((state: RootState) => state.posts); 13 | const location = useLocation(); 14 | const dispatch = useRPDispatch(); 15 | const [post, setPost] = useState(null); 16 | const [content, setContent] = useState(null); 17 | 18 | useEffect(() => { 19 | const params = new URLSearchParams(location.search); 20 | const pID = params.get("id"); 21 | if (pID) { 22 | dispatch(actionMethods.loadSinglePost(parseInt(pID, 10))); 23 | } 24 | }, []); 25 | 26 | useEffect(() => { 27 | if (!postsState?.post) { 28 | return; 29 | } 30 | setPost(postsState.post); 31 | }, [postsState]); 32 | 33 | useEffect(() => { 34 | if (!post) { 35 | return; 36 | } 37 | let contentVal = null; 38 | let catTags = null; 39 | catTags = (post.categoryTags || []).map((cats) => { 40 | return ( 41 |

42 | {cats?.name || ""} 43 |

44 | ); 45 | }); 46 | contentVal = ( 47 | <> 48 | 49 | {reactHtmlParser(post.title.rendered)} 50 | 51 | 52 | {catTags} 53 | 54 | 55 | {post.title.rendered} 56 | 57 | 58 | {reactHtmlParser(post.content.rendered)} 59 | 60 | 61 | ); 62 | setContent(contentVal); 63 | }, [post]); 64 | 65 | if (!post) { 66 | return

Post loading...

; 67 | } 68 | 69 | return ( 70 | 71 | {content} 72 | 73 | ); 74 | }; 75 | 76 | export default SinglePost; 77 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/store/actions/users.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import axios from "@/axios-wp"; 3 | 4 | import { User } from "@rptypes/users.types"; 5 | import { WPUserType } from "@rptypes/wptypes"; 6 | 7 | export const userSignup = createAsyncThunk("users/userSignup", async (user: User | WPUserType, { rejectWithValue }) => { 8 | const newUser = await axios.post("/wp/v2/users", user); 9 | if (newUser?.data) { 10 | const newUserData = newUser.data; 11 | return { user: newUserData, userLoading: false }; 12 | } 13 | return rejectWithValue({ error: "User sign up failed", userLoading: false }); 14 | }); 15 | 16 | export const userSignin = createAsyncThunk("users/userSignin", async (user: User | WPUserType, { rejectWithValue }) => { 17 | const userResponse = await axios.post("/simple-jwt-authentication/v1/token", user); 18 | if (userResponse?.data) { 19 | const userDets = { 20 | username: userResponse.data.user_nicename, 21 | email: userResponse.data.user_email, 22 | displayname: userResponse.data.user_display_name, 23 | }; 24 | // Set token and email in local storage in case Redux data is lost 25 | localStorage.setItem("token", userResponse.data.token); 26 | localStorage.setItem("email", userResponse.data.user_email); 27 | return { user: userDets, token: userResponse.data.token, userLoading: false }; 28 | } 29 | return rejectWithValue({ error: "User sign in failed", userLoading: false }); 30 | }); 31 | 32 | export const userSignout = createAsyncThunk("users/userSignout", async (token: string, { rejectWithValue }) => { 33 | const userResponse = await axios.post( 34 | "/simple-jwt-authentication/v1/token/revoke", 35 | {}, 36 | { headers: { Authorization: `Bearer ${token}` } }, 37 | ); 38 | 39 | if (userResponse) { 40 | // Clear local storage 41 | localStorage.removeItem("token"); 42 | localStorage.removeItem("email"); 43 | return { status: true }; 44 | } 45 | return rejectWithValue({ status: true, error: "Error signing user out" }); 46 | }); 47 | 48 | export const validateToken = createAsyncThunk("users/validateToken", async (url: string, { rejectWithValue }) => { 49 | let tokenVal: string | null = null; 50 | let emailVal: string | null = null; 51 | if (localStorage.getItem("token")) { 52 | tokenVal = localStorage.getItem("token"); 53 | emailVal = localStorage.getItem("email"); 54 | } 55 | if (tokenVal && emailVal) { 56 | const tokenResponse = await axios.post( 57 | "/simple-jwt-authentication/v1/token/validate", 58 | {}, 59 | { headers: { Authorization: `Bearer ${tokenVal}` } }, 60 | ); 61 | if (tokenResponse.data.data.status === 200) { 62 | return { token: tokenVal || "", email: emailVal || "", redirectTo: url }; 63 | } 64 | } 65 | return rejectWithValue({ error: "Token validation failed" }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, ReactElement } from "react"; 2 | import DOMPurify from "dompurify"; 3 | import { AnimatedSwitch } from "react-router-transition"; 4 | import { Route } from "react-router-dom"; 5 | 6 | import Navigation from "@containers/Navigation/Navigation"; 7 | import Blog from "@containers/Blog/Blog"; 8 | import SinglePost from "@components/Post/SinglePost"; 9 | // import Auth from "./containers/Auth/Auth"; 10 | 11 | import useWPPages from "@hooks/useWPPages"; 12 | import WPPage from "@components/WPPage"; 13 | import type { RPPagesHookType } from "@rptypes/pages.types"; 14 | import { baseNavItems } from "@commons/constants"; 15 | import "./App.css"; 16 | 17 | // TODO: Find a better solution 18 | const getStaticComponent = (linkSlug: string): ReactElement => { 19 | let finalComp = ; 20 | switch (linkSlug) { 21 | // case "/auth/signup": // TODO: Add auth 22 | // case "/auth/signin": 23 | // finalComp = ; 24 | // break; 25 | case "/post": 26 | finalComp = ; 27 | break; 28 | default: 29 | break; 30 | } 31 | 32 | return finalComp; 33 | }; 34 | 35 | function App() { 36 | const wpPages: RPPagesHookType = useWPPages(); 37 | const [wpRoutesList, setWPRoutesList] = useState[] | null>(null); // Separate state for Wordpress routes 38 | const [routes, setRoutes] = useState[]>([]); // Final React Router routes 39 | 40 | useEffect(() => { 41 | if (wpRoutesList?.length || wpPages?.pagesLoading || !Object.keys(wpPages?.pages).length) { 42 | return; 43 | } 44 | const { pages } = wpPages || {}; 45 | const pageSlugs = Object.keys(pages); 46 | const newRoutesList: ReactElement[] = pageSlugs.map((slug) => { 47 | const pageDetails = pages[slug]; 48 | return ( 49 | ( 53 | 54 |
55 | 56 | )} 57 | /> 58 | ); 59 | }); 60 | setWPRoutesList(newRoutesList); 61 | }, [wpPages]); 62 | 63 | useEffect(() => { 64 | if (!wpRoutesList?.length) { 65 | return; 66 | } 67 | const staticRoutes: ReactElement[] = baseNavItems.map((item) => ( 68 | getStaticComponent(item.link)} /> 69 | )); 70 | const finalRoutesList = [...staticRoutes, ...wpRoutesList]; 71 | setRoutes(finalRoutesList); 72 | }, [wpRoutesList]); 73 | 74 | if (!routes?.length || wpPages?.pagesLoading) { 75 | return
Loading...
; 76 | } 77 | 78 | return ( 79 |
80 |
81 | 82 |
83 | 89 | {routes} 90 | 91 |
92 | ); 93 | } 94 | 95 | export default App; 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactpress", 3 | "version": "2.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.0.0", 7 | "@reduxjs/toolkit": "^1.6.1", 8 | "@types/react-redux": "^7.1.18", 9 | "antd": "^4.4.1", 10 | "axios": "^0.21.1", 11 | "bootstrap": "^5.0.0", 12 | "dompurify": "^2.3.0", 13 | "prop-types": "^15.7.2", 14 | "react": "^17.0.0", 15 | "react-dom": "^17.0.0", 16 | "react-html-parser": "^2.0.2", 17 | "react-redux": "^7.2.4", 18 | "react-router-dom": "^5.2.0", 19 | "react-router-transition": "^2.0.0", 20 | "react-scripts": "^4.0.0", 21 | "reactstrap": "^8.4.0", 22 | "snyk": "^1.606.0", 23 | "styled-components": "^5.3.0", 24 | "typescript": "^4.0.0" 25 | }, 26 | "scripts": { 27 | "start": "craco start", 28 | "build": "react-scripts build", 29 | "test": "jest", 30 | "rs-test": "react-scripts test", 31 | "eject": "react-scripts eject", 32 | "test:watch": "jest --watch" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": [ 38 | ">0.2%", 39 | "not dead", 40 | "not ie <= 11", 41 | "not op_mini all" 42 | ], 43 | "jest": { 44 | "setupFiles": [ 45 | "./src/setupTests.js" 46 | ], 47 | "transform": { 48 | "\\.(ts|tsx)$": "ts-jest" 49 | }, 50 | "transformIgnorePatterns": [ 51 | "node_modules" 52 | ], 53 | "moduleFileExtensions": [ 54 | "js", 55 | "jsx", 56 | "ts", 57 | "tsx" 58 | ], 59 | "moduleDirectories": [ 60 | "node_modules", 61 | "src" 62 | ], 63 | "moduleNameMapper": { 64 | "\\.(css|less|scss)$": "identity-obj-proxy" 65 | }, 66 | "snapshotSerializers": [ 67 | "enzyme-to-json" 68 | ] 69 | }, 70 | "devDependencies": { 71 | "@babel/preset-env": "^7.16.4", 72 | "@babel/preset-react": "^7.16.0", 73 | "@types/enzyme": "^3.10.5", 74 | "@types/jest": "^26.0.23", 75 | "@types/node": "^16.0.0", 76 | "@types/react": "^17.0.0", 77 | "@types/react-dom": "^17.0.0", 78 | "@types/react-router-dom": "^5.1.5", 79 | "@typescript-eslint/eslint-plugin": "^4.29.0", 80 | "@typescript-eslint/parser": "^4.29.0", 81 | "babel-plugin-styled-components": "^1.13.2", 82 | "enzyme": "^3.11.0", 83 | "enzyme-adapter-react-16": "^1.15.0", 84 | "enzyme-to-json": "^3.5.0", 85 | "eslint-config-airbnb-typescript": "^12.0.2", 86 | "eslint-config-prettier": "^8.0.0", 87 | "eslint-plugin-import": "^2.20.1", 88 | "eslint-plugin-jsx-a11y": "^6.2.3", 89 | "eslint-plugin-prettier": "^3.1.4", 90 | "eslint-plugin-react": "^7.19.0", 91 | "eslint-plugin-react-hooks": "^2.5.0", 92 | "husky": "^7.0.1", 93 | "jest-styled-components": "^7.0.5", 94 | "prettier": "^2.0.5", 95 | "pretty-quick": "^3.1.1", 96 | "react-test-renderer": "^16.13.0", 97 | "redux-mock-store": "^1.5.4", 98 | "ts-jest":"^26.0.1", 99 | "typescript-plugin-styled-components": "^2.0.0" 100 | }, 101 | "prettier": { 102 | "semi": true, 103 | "printWidth": 120, 104 | "singleQuote": false, 105 | "bracketSpacing": true, 106 | "trailingComma": "all" 107 | }, 108 | "husky": { 109 | "hooks": { 110 | "pre-commit": "pretty-quick --staged --pattern '**/*.*(js|jsx|ts|tsx)'" 111 | } 112 | }, 113 | "snyk": true 114 | } 115 | -------------------------------------------------------------------------------- /src/containers/Blog/Blog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, SyntheticEvent, useEffect } from "react"; 2 | import { Container, Row, Col, InputGroup, InputGroupAddon, Button, Alert } from "reactstrap"; 3 | import { Spin } from "antd"; 4 | import { useHistory } from "react-router-dom"; 5 | 6 | import { RootState } from "@/index"; 7 | import { useRPSelector, useRPDispatch } from "store/store"; 8 | import { WPPost } from "@rptypes/wptypes"; 9 | import * as actionMethods from "@store/actions/index.actions"; 10 | import Post from "@components/Post/Post"; 11 | 12 | type PostsList = JSX.Element[] | []; 13 | 14 | const Blog = () => { 15 | const [searchTerm, setSearchTerm] = useState(""); 16 | const [onAlert, setOnAlert] = useState(false); 17 | const [internalError, setInternalError] = useState(""); 18 | const [postsList, setPostsList] = useState([]); 19 | 20 | const history = useHistory(); 21 | 22 | const postsState = useRPSelector((state: RootState) => state.posts); 23 | 24 | const dispatch = useRPDispatch(); 25 | 26 | const { postsLoading, posts = [], error } = postsState; 27 | 28 | const resetError = () => { 29 | setInternalError(""); 30 | }; 31 | 32 | const toggleAlertShow = () => { 33 | if (internalError && onAlert) { 34 | resetError(); 35 | } 36 | setOnAlert(!onAlert); 37 | }; 38 | 39 | const readMoreHandler = (id: number) => { 40 | // Send selected post to Post component 41 | history.push(`/post?id=${id}`); 42 | }; 43 | 44 | const onSearchHandler = () => { 45 | if (searchTerm !== "") { 46 | dispatch(actionMethods.searchAllPosts(searchTerm)); 47 | } else { 48 | setInternalError("Search Term cannot be empty!"); 49 | setOnAlert(true); 50 | } 51 | }; 52 | 53 | const searchInputChanged = (event: SyntheticEvent) => { 54 | const element = event.target as HTMLInputElement; 55 | setSearchTerm(element.value); 56 | }; 57 | 58 | useEffect(() => { 59 | dispatch(actionMethods.loadAllPosts(5)); 60 | }, []); 61 | 62 | useEffect(() => { 63 | if (postsLoading || !posts?.length || postsList.length) { 64 | return; 65 | } 66 | const updatedPosts = posts.map((post: WPPost) => { 67 | return ( 68 | 76 | ); 77 | }); 78 | setPostsList(updatedPosts); 79 | }, [posts, postsLoading]); 80 | 81 | return ( 82 | 83 | {error ? ( 84 | 85 | {error} 86 | 87 | ) : null} 88 | {internalError ? ( 89 | 90 | {internalError} 91 | 92 | ) : null} 93 | 94 | 95 |

All Posts

96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 |
106 | {postsLoading ? : null} 107 | {!postsLoading && postsList?.length ? postsList : null} 108 |
109 | ); 110 | }; 111 | 112 | export default Blog; 113 | -------------------------------------------------------------------------------- /src/store/actions/posts.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import axiosOrig from "axios"; 3 | import axios from "@/axios-wp"; 4 | 5 | import { SINGLE_POST_ACTION, SEARCH_POSTS_ACTION, GET_ALL_POSTS, RPPost } from "@rptypes/posts.types"; 6 | import { WPMedia, WPPost, WPCategory } from "@rptypes/wptypes"; 7 | 8 | type MediaLinkType = { 9 | media_link?: string; 10 | }; 11 | 12 | export const loadAllPosts = createAsyncThunk("posts/loadAllPosts", async (perpage: number, { rejectWithValue }) => { 13 | const postsResponse = await axios.get(`/wp/v2/posts?per_page=${perpage}`); 14 | if (postsResponse) { 15 | const mediaIds: [number?] = []; 16 | postsResponse.data.forEach((post: WPPost) => { 17 | mediaIds.push(post.featured_media); 18 | }); 19 | // Retrieve media from WP DB 20 | const mediaRes = await axios.get(`/wp/v2/media?include=${mediaIds.join(",")}`); 21 | if (mediaRes) { 22 | let updatedPosts = null; 23 | updatedPosts = postsResponse.data.map((postObj: WPPost) => { 24 | const mediaLinkObj: MediaLinkType = {}; 25 | mediaRes.data.forEach((media: WPMedia) => { 26 | if (media.id === postObj.featured_media) { 27 | mediaLinkObj.media_link = media.guid.rendered; 28 | } 29 | }); 30 | return { 31 | ...postObj, 32 | ...mediaLinkObj, 33 | }; 34 | }); 35 | return { type: GET_ALL_POSTS, posts: updatedPosts, postsLoading: false }; 36 | } 37 | } 38 | return rejectWithValue({ type: GET_ALL_POSTS, posts: [], postsLoading: false, error: "Error loading posts" }); 39 | }); 40 | 41 | export const loadSinglePost = createAsyncThunk("posts/loadSinglePost", async (pid: number, { rejectWithValue }) => { 42 | const postResponse = await axios.get(`/wp/v2/posts/${pid}`); 43 | if (postResponse) { 44 | const post: WPPost = postResponse.data; 45 | const postObject: RPPost = { 46 | ...post, 47 | }; 48 | // Retrieve featured image 49 | const mediaId = post.featured_media; 50 | if (mediaId) { 51 | const mediaRes = await axios.get(`/wp/v2/media/${mediaId}`); 52 | postObject.medialink = mediaRes.data.guid.rendered; 53 | } 54 | if (post.categories?.length) { 55 | const categoriesResp = await axios.get(`wp/v2/categories?include=${post.categories.join(",")}`); 56 | postObject.categoryTags = [...categoriesResp.data]; 57 | } 58 | 59 | return { type: SINGLE_POST_ACTION, post: postObject, postsLoading: false }; 60 | } 61 | return rejectWithValue({ type: SINGLE_POST_ACTION, post: null, postsLoading: false, error: "Error loading post" }); 62 | }); 63 | 64 | export const searchAllPosts = createAsyncThunk("posts/searchAllPosts", async (term: string, { rejectWithValue }) => { 65 | const postResults = await axios.get(`/wp/v2/posts?search=${term}`); 66 | if (postResults?.data) { 67 | const mediaIds: [number?] = []; 68 | postResults.data.forEach((post: WPPost) => { 69 | mediaIds.push(post.featured_media); 70 | }); 71 | if (mediaIds.length !== 0) { 72 | const mediaResults = await axios.get(`/wp/v2/media?include${mediaIds.join(",")}`); 73 | if (mediaResults?.data) { 74 | let updatedPosts = null; 75 | updatedPosts = postResults.data.map((post: WPPost) => { 76 | const mediaLinkObj: MediaLinkType = {}; 77 | mediaResults.data.forEach((media: WPMedia) => { 78 | if (media.id === post.featured_media) { 79 | mediaLinkObj.media_link = media.guid.rendered; 80 | } 81 | }); 82 | return { 83 | ...post, 84 | ...mediaLinkObj, 85 | }; 86 | }); 87 | 88 | return { type: SEARCH_POSTS_ACTION, posts: updatedPosts, postsLoading: false }; 89 | } 90 | } 91 | } 92 | return rejectWithValue({ type: SEARCH_POSTS_ACTION, posts: [], postsLoading: false, error: "No posts found" }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/containers/Auth/Auth.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Switch, Route } from "react-router-dom"; 3 | import { connect } from "react-redux"; 4 | 5 | import * as actionMethods from "../../store/actions/index.actions"; 6 | import SignUp from "../../components/Auth/SignUp"; 7 | import SignIn from "../../components/Auth/SignIn"; 8 | import SignOut from "../../components/Auth/Signout"; 9 | 10 | class Auth extends Component { 11 | state = { 12 | user: { 13 | firstName: "", 14 | lastName: "", 15 | email: "", 16 | password: "", 17 | username: "", 18 | locale: "en_US", 19 | description: "", 20 | name: "", 21 | }, 22 | isNewUser: false, 23 | alertText: null, 24 | }; 25 | 26 | componentDidMount() { 27 | const params = new URLSearchParams(this.props.location.search); 28 | if (params.get("user")) { 29 | this.setState({ isNewUser: true }); 30 | } 31 | } 32 | /* Validate inputs for the User fields for Signin and Sign Up forms 33 | * 1. Check for empty input ~ 34 | * 2. Check for password strength (OWASP regex) X 35 | * 3. Check email validity X 36 | * 4. Check for blacklisted words x 37 | * 5. Trigger error texts x 38 | */ 39 | 40 | validateInputs(forForm) { 41 | // Empty or false inputs 42 | if (this.state.user.username === "" || !this.state.user.username) { 43 | return false; 44 | } 45 | if (this.state.user.password === "" || !this.state.user.password) { 46 | return false; 47 | } 48 | if (forForm === "signup") { 49 | if (this.state.user.email === "" || !this.state.user.email) { 50 | return false; 51 | } 52 | if (this.state.user.firstName === "" || this.state.user.lastName === "") { 53 | return false; 54 | } 55 | } 56 | return true; 57 | } 58 | 59 | onUserSignUp = () => { 60 | const tempUser = { 61 | ...this.state.user, 62 | }; 63 | tempUser.username = tempUser.email; 64 | tempUser.name = tempUser.firstName + " " + tempUser.lastName; 65 | this.setState({ user: tempUser }, () => { 66 | // TODO: Validate inputs 67 | let isValid = this.validateInputs("signup"); 68 | if (isValid) { 69 | // Assign email to username 70 | this.props.onUserSignUpSubmit(this.state.user); 71 | } 72 | }); 73 | }; 74 | 75 | onUserSignin = () => { 76 | // TODO: Validate inputs 77 | if (this.state.user.username === "" || this.state.user.password === "") { 78 | // TODO: ADD ERROR DIALOG 79 | } else { 80 | // Submit the form 81 | const userDets = { 82 | username: this.state.user.username, 83 | password: this.state.user.password, 84 | }; 85 | this.props.onUserSigninSubmit(userDets); 86 | } 87 | }; 88 | 89 | onInputClicked = (event, forField, forForm) => { 90 | const tempUser = this.state.user; 91 | tempUser[forField] = event.target.value; 92 | this.setState({ user: tempUser }); 93 | }; 94 | 95 | render() { 96 | return ( 97 | 98 | {/* this.props.token ? : null */} 99 | 100 | ( 103 | 109 | )} 110 | /> 111 | ( 114 | 115 | )} 116 | /> 117 | } /> 118 | 119 | 120 | ); 121 | } 122 | } 123 | 124 | const mapStateToProps = (state) => { 125 | return { 126 | userInfo: state.usersRed.userInfo, 127 | token: state.usersRed.token, 128 | }; 129 | }; 130 | 131 | const mapDispatchToProps = (dispatch) => { 132 | return { 133 | onUserSigninSubmit: (user) => { 134 | dispatch(actionMethods.userSignin(user)); 135 | }, 136 | onUserSignUpSubmit: (user) => { 137 | dispatch(actionMethods.userSignup(user)); 138 | }, 139 | }; 140 | }; 141 | 142 | export default connect(mapStateToProps, mapDispatchToProps)(Auth); 143 | -------------------------------------------------------------------------------- /src/types/wptypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | 3 | export type WPPost = { 4 | id: number; 5 | date: Date; 6 | date_gmt: Date; 7 | guid: { 8 | rendered: string; 9 | }; 10 | modified: Date; 11 | modified_gmt: Date; 12 | slug: string; 13 | status: string; 14 | type: string; 15 | link: string; 16 | title: { 17 | rendered: string; 18 | }; 19 | content: { 20 | rendered: string; 21 | protected: boolean; 22 | }; 23 | excerpt: { 24 | rendered: string; 25 | protected: boolean; 26 | }; 27 | author: number; 28 | featured_media: number; 29 | comment_status: string; 30 | ping_status: string; 31 | sticky: boolean; 32 | template: string; 33 | format: string; 34 | meta: { 35 | hide_page_title: string; 36 | _coblocks_attr: string; 37 | _coblocks_dimensions: string; 38 | _coblocks_responsive_height: string; 39 | _coblocks_accordion_ie_support: string; 40 | }; 41 | categories: [number]; 42 | tags: [string]; 43 | _links: { 44 | self: [object]; 45 | collection: [object]; 46 | about: [object]; 47 | author: [object]; 48 | replies: [object]; 49 | "version-history": [object]; 50 | "wp:featuredmedia": [object]; 51 | "wp:attachment": [object]; 52 | "wp:term": [object]; 53 | curies: [object]; 54 | }; 55 | media_link: "string"; 56 | }; 57 | 58 | export type WPCategory = { 59 | id: number; 60 | name: string; 61 | }; 62 | 63 | type MediaSizesType = { 64 | file: string; 65 | width: number; 66 | height: number; 67 | mime_type: string; 68 | source_url: string; 69 | }; 70 | 71 | export type WPMedia = { 72 | id: number; 73 | date: Date; 74 | date_gmt: Date; 75 | guid: { 76 | rendered: string; 77 | }; 78 | modified: Date; 79 | modified_gmt: Date; 80 | slug: string; 81 | status: string; 82 | type: string; 83 | link: string; 84 | title: { 85 | rendered: string; 86 | }; 87 | author: number; 88 | comment_status: string; 89 | ping_status: string; 90 | template: string; 91 | meta: { 92 | hide_page_title: string; 93 | _coblocks_attr: string; 94 | _coblocks_dimensions: string; 95 | _coblocks_responsive_height: string; 96 | _coblocks_accordion_ie_support: string; 97 | }; 98 | description: { 99 | rendered: string; 100 | }; 101 | caption: { 102 | rendered: string; 103 | }; 104 | alt_text: string; 105 | media_type: string; 106 | mime_type: string; 107 | media_details: { 108 | width: number; 109 | height: number; 110 | file: string; 111 | sizes: { 112 | medium: MediaSizesType; 113 | large: MediaSizesType; 114 | thumbnail: MediaSizesType; 115 | medium_large: MediaSizesType; 116 | full: MediaSizesType; 117 | }; 118 | image_meta: { 119 | aperture: string; 120 | credit: string; 121 | camera: string; 122 | caption: string; 123 | created_timestamp: string; 124 | copyright: string; 125 | focal_length: string; 126 | iso: string; 127 | shutter_speed: string; 128 | title: string; 129 | orientation: string; 130 | keywords: [string]; 131 | }; 132 | post: null; 133 | source_url: string; 134 | _links: { 135 | self: [object]; 136 | collection: [object]; 137 | about: [object]; 138 | author: [object]; 139 | replies: [object]; 140 | }; 141 | }; 142 | }; 143 | 144 | export type WPUserType = { 145 | user_nicename?: string; 146 | user_email?: string; 147 | user_display_name?: string; 148 | }; 149 | 150 | export type WPPage = { 151 | id: number; 152 | date: Date; 153 | date_gmt: Date; 154 | guid: { 155 | rendered: string; 156 | }; 157 | modified: Date; 158 | modified_gmt: Date; 159 | slug: string; 160 | status: string; 161 | type: string; 162 | link: string; 163 | title: { 164 | rendered: string; 165 | }; 166 | content: { 167 | rendered: string; 168 | protected: boolean; 169 | }; 170 | excerpt: { 171 | rendered: string; 172 | protected: boolean; 173 | }; 174 | author: number; 175 | featured_media: number; 176 | parent: number; 177 | menu_order: number; 178 | comment_status: string; 179 | ping_status: string; 180 | template: string; 181 | meta: Array; 182 | _links: { 183 | self: [object]; 184 | collection: [object]; 185 | about: [object]; 186 | author: [object]; 187 | replies: [object]; 188 | "version-history": [object]; 189 | "wp:featuredmedia": [object]; 190 | "wp:attachment": [object]; 191 | curies: [object]; 192 | }; 193 | }; 194 | --------------------------------------------------------------------------------