chore: add boilerplate for react + redux

This commit is contained in:
tsukino
2024-01-24 15:18:25 +08:00
parent e3f81ceed1
commit 35f77b0e16
21 changed files with 14190 additions and 1 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}

36
.eslintrc Normal file
View File

@@ -0,0 +1,36 @@
{
"root": true,
"extends": ["prettier", "plugin:@typescript-eslint/recommended"],
"plugins": ["prettier", "@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": 1,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/ban-ts-comment": 0,
"no-undef": "error",
"padding-line-between-statements": "error"
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest"
},
"env": {
"webextensions": true,
"es6": true,
"browser": true,
"node": true
},
"settings": {
"import/resolver": "typescript"
},
"ignorePatterns": [
"node_modules",
"zip",
"build",
"wasm",
"tlsn",
"util",
"webpack.config.js"
]
}

4
.gitignore vendored
View File

@@ -0,0 +1,4 @@
**/node_modules
**/.DS_Store
.idea
build

8
.prettierignore Normal file
View File

@@ -0,0 +1,8 @@
build
node_modules
wasm
tlsn
postcss.config.js
webpack.config.js
*.json
*.scss

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "always"
}

13555
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "NODE_ENV=production webpack --config webpack.config.js",
"dev": "NODE_ENV=development webpack-dev-server --config webpack.config.js --hot"
},
"repository": {
"type": "git",
@@ -15,5 +16,71 @@
"bugs": {
"url": "https://github.com/tlsnotary/explorer/issues"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",
"buffer": "^6.0.3",
"charwise": "^3.0.1",
"classnames": "^2.3.2",
"fast-deep-equal": "^3.1.3",
"isomorphic-fetch": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.2",
"react-router": "^6.15.0",
"react-router-dom": "^6.15.0",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"tailwindcss": "^3.3.3"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@types/chrome": "^0.0.202",
"@types/node": "^20.4.10",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3",
"@types/redux-logger": "^3.0.9",
"@types/webextension-polyfill": "^0.10.7",
"babel-eslint": "^10.1.0",
"babel-loader": "^9.1.2",
"babel-preset-react-app": "^10.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"eslint": "^8.31.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"postcss-loader": "^7.3.3",
"postcss-preset-env": "^9.1.1",
"prettier": "^3.0.2",
"react-refresh": "^0.14.0",
"react-refresh-typescript": "^2.0.7",
"sass": "^1.57.1",
"sass-loader": "^13.2.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.2",
"type-fest": "^3.5.2",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
},
"homepage": "https://github.com/tlsnotary/explorer#readme"
}

4
postcss.config.js Normal file
View File

@@ -0,0 +1,4 @@
const tailwindcss = require("tailwindcss");
module.exports = {
plugins: ["postcss-preset-env", tailwindcss],
};

View File

@@ -0,0 +1,45 @@
.button {
@apply bg-slate-100;
@apply text-slate-500;
@apply font-bold;
@apply px-2 py-0.5;
user-select: none;
&:hover {
@apply text-slate-600;
@apply bg-slate-200;
}
&:active {
@apply text-slate-700;
@apply bg-slate-300;
}
&--primary {
@apply bg-primary/[0.8];
@apply text-white;
&:hover {
@apply bg-primary/[0.9];
@apply text-white;
}
&:active {
@apply bg-primary;
@apply text-white;
}
}
&:disabled {
@apply opacity-50;
@apply select-none;
&:hover {
@apply text-slate-400;
}
&:active {
@apply text-slate-400;
}
}
}

View File

@@ -0,0 +1,41 @@
import React, { ButtonHTMLAttributes, ReactElement } from 'react';
import classNames from 'classnames';
import './index.scss';
import Icon from '../Icon';
type Props = {
className?: string;
btnType?: 'primary' | 'secondary' | '';
loading?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export default function Button(props: Props): ReactElement {
const {
className,
btnType = '',
children,
onClick,
disabled,
loading,
// Must select all non-button props here otherwise react-dom will show warning
...btnProps
} = props;
return (
<button
className={classNames(
'flex flex-row flex-nowrap items-center',
'h-10 px-4 button transition-colors',
{
'button--primary': btnType === 'primary',
'button--secondary': btnType === 'secondary',
'cursor-default': disabled || loading,
},
className
)}
onClick={!disabled && !loading ? onClick : undefined}
disabled={disabled || loading}
{...btnProps}>
{loading ? <Icon className="animate-spin" fa="fa-solid fa-spinner" size={2} /> : children}
</button>
);
}

View File

View File

@@ -0,0 +1,37 @@
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import './index.scss';
type Props = {
url?: string;
fa?: string;
className?: string;
size?: number;
onClick?: MouseEventHandler;
children?: ReactNode;
};
export default function Icon(props: Props): ReactElement {
const { url, size = 1, className = '', fa, onClick, children } = props;
return (
<div
className={classNames(
'bg-contain bg-center bg-no-repeat icon',
{
'cursor-pointer': onClick,
},
className,
)}
style={{
backgroundImage: url ? `url(${url})` : undefined,
width: !fa ? `${size}rem` : undefined,
height: !fa ? `${size}rem` : undefined,
}}
onClick={onClick}
>
{!url && !!fa && <i className={fa} style={{ fontSize: `${size}rem` }} />}
{children}
</div>
);
}

22
src/index.tsx Normal file
View File

@@ -0,0 +1,22 @@
import 'isomorphic-fetch';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import store from './store';
import App from './pages/App';
(async () => {
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
})();
if ((module as any).hot) {
(module as any).hot.accept();
}

10
src/pages/App/index.scss Normal file
View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/solid";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/regular";

23
src/pages/App/index.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { ReactElement } from 'react';
import "./index.scss";
import Button from '../../components/Button';
import { useDispatch } from 'react-redux';
import { asyncIncrementCounter, incrementCounter, useCounter, useLoading } from '../../store/counter';
export default function App(): ReactElement {
const dispatch = useDispatch();
const counter = useCounter();
const loading = useLoading();
return (
<div className="app flex flex-col gap-4">
{`Clicked ${counter} times`}
<Button className="w-fit" onClick={() => dispatch(incrementCounter())}>
Increment
</Button>
<Button className="w-fit" onClick={() => dispatch(asyncIncrementCounter(1000))} loading={loading}>
Wait 1s + Increment
</Button>
</div>
);
}

80
src/store/counter.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useSelector } from 'react-redux';
import deepEqual from 'fast-deep-equal';
import type { AppRootState } from './index';
import { Dispatch } from 'redux';
export enum ActionType {
Increment = 'counter/increment',
SetLoading = 'counter/setLoading',
}
export type Action<payload = any> = {
type: ActionType;
payload: payload;
error?: boolean;
meta?: any;
};
type State = {
value: number;
loading: boolean;
};
const initState: State = {
value: 0,
loading: false,
};
export const incrementCounter = (): Action => ({
type: ActionType.Increment,
payload: null,
});
export const setLoading = (loading = false): Action<boolean> => ({
type: ActionType.SetLoading,
payload: loading,
});
export const asyncIncrementCounter = (timeout = 1000): any => async (dispatch: Dispatch, getState: () => AppRootState) => {
dispatch(setLoading(true));
await new Promise(r => setTimeout(r, timeout));
dispatch(incrementCounter());
dispatch(setLoading(false));
};
export default function counter(state = initState, action: Action): State {
switch (action.type) {
case ActionType.Increment:
return handleIncrement(state, action);
case ActionType.SetLoading:
return handleSetLoading(state, action);
default:
return state;
}
}
function handleIncrement(state: State, action: Action): State {
return {
...state,
value: state.value + 1,
};
}
function handleSetLoading(state: State, action: Action<boolean>): State {
return {
...state,
loading: action.payload,
};
}
export function useCounter() {
return useSelector((state: AppRootState) => {
return state.counter.value;
}, deepEqual);
}
export function useLoading() {
return useSelector((state: AppRootState) => {
return state.counter.loading;
}, deepEqual);
}

32
src/store/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import { applyMiddleware, combineReducers, createStore } from 'redux';
import thunk from 'redux-thunk';
import { createLogger } from 'redux-logger';
import counter from './counter';
const rootReducer = combineReducers({
counter,
});
export type AppRootState = ReturnType<typeof rootReducer>;
const createStoreWithMiddleware =
process.env.NODE_ENV === 'development'
? applyMiddleware(
thunk,
createLogger({
collapsed: true,
}),
)(createStore)
: applyMiddleware(
thunk,
)(createStore);
function configureAppStore() {
return createStoreWithMiddleware(
rootReducer,
);
}
const store = configureAppStore();
export default store;

12
static/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Popup</title>
</head>
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
</html>

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
primary: '#243f5f',
},
},
},
plugins: [],
};

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"noEmit": false,
"jsx": "react"
},
"include": ["src"],
"exclude": ["build", "node_modules"]
}

169
webpack.config.js Executable file
View File

@@ -0,0 +1,169 @@
var webpack = require("webpack"),
path = require("path"),
CopyWebpackPlugin = require("copy-webpack-plugin"),
HtmlWebpackPlugin = require("html-webpack-plugin"),
TerserPlugin = require("terser-webpack-plugin");
var { CleanWebpackPlugin } = require("clean-webpack-plugin");
var ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
var ReactRefreshTypeScript = require("react-refresh-typescript");
const ASSET_PATH = process.env.ASSET_PATH || "/";
var alias = {};
var fileExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"eot",
"otf",
"svg",
"ttf",
"woff",
"woff2",
];
const isDevelopment = process.env.NODE_ENV !== "production";
var options = {
mode: process.env.NODE_ENV || "development",
ignoreWarnings: [
/Circular dependency between chunks with runtime/,
/ResizeObserver loop completed with undelivered notifications/
],
entry: {
index: path.join(__dirname, "src", "index.tsx"),
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "build"),
clean: true,
publicPath: ASSET_PATH,
},
module: {
rules: [
{
// look for .css or .scss files
test: /\.(css|scss)$/,
// in the `src` directory
use: [
{
loader: "style-loader",
},
{
loader: "css-loader",
options: { importLoaders: 1 },
},
{
loader: "postcss-loader",
},
{
loader: "sass-loader",
options: {
sourceMap: true,
},
},
],
},
{
test: new RegExp(".(" + fileExtensions.join("|") + ")$"),
type: "asset/resource",
exclude: /node_modules/,
// loader: 'file-loader',
// options: {
// name: '[name].[ext]',
// },
},
{
test: /\.html$/,
loader: "html-loader",
exclude: /node_modules/,
},
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: require.resolve("ts-loader"),
options: {
getCustomTransformers: () => ({
before: [isDevelopment && ReactRefreshTypeScript()].filter(
Boolean
),
}),
transpileOnly: isDevelopment,
},
},
],
},
{
test: /\.(js|jsx)$/,
use: [
{
loader: "source-map-loader",
},
{
loader: require.resolve("babel-loader"),
options: {
plugins: [
isDevelopment && require.resolve("react-refresh/babel"),
].filter(Boolean),
},
},
],
exclude: /node_modules/,
},
],
},
resolve: {
alias: alias,
extensions: fileExtensions
.map((extension) => "." + extension)
.concat([".js", ".jsx", ".ts", ".tsx", ".css"]),
},
plugins: [
isDevelopment && new ReactRefreshWebpackPlugin(),
new CleanWebpackPlugin({ verbose: false }),
new webpack.ProgressPlugin(),
// expose and write the allowed env vars on the compiled bundle
new webpack.EnvironmentPlugin(["NODE_ENV"]),
new HtmlWebpackPlugin({
template: path.join(__dirname, "static", "index.html"),
filename: "index.html",
chunks: ["index"],
cache: false,
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
}),
].filter(Boolean),
infrastructureLogging: {
level: "info",
},
// Required by wasm-bindgen-rayon, in order to use SharedArrayBuffer on the Web
// Ref:
// - https://github.com/GoogleChromeLabs/wasm-bindgen-rayon#setting-up
// - https://web.dev/i18n/en/coop-coep/
devServer: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
}
},
};
if (process.env.NODE_ENV === "development") {
options.devtool = "cheap-module-source-map";
} else {
options.optimization = {
minimize: true,
minimizer: [
new TerserPlugin({
extractComments: false,
}),
],
};
}
module.exports = options;