diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..42d30a5
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,47 @@
+module.exports = {
+ root: true,
+ parser: '@babel/eslint-parser',
+ plugins: ['jsx-a11y', 'react', 'react-hooks'],
+ extends: [
+ 'eslint:recommended',
+ 'plugin:react/recommended',
+ 'plugin:react-hooks/recommended',
+ 'plugin:jsx-a11y/recommended',
+ ],
+
+ env: {
+ browser: true,
+ commonjs: true,
+ es6: true,
+ node: true,
+ },
+
+ parserOptions: {
+ ecmaVersion: 8,
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ generators: true,
+ experimentalObjectRestSpread: true,
+ },
+ },
+
+ settings: {
+ 'import/ignore': ['node_modules', '\\.(json|css|jpg|png|gif|eot|svg|ttf|woff|woff2|mp4|webm)$'],
+ 'import/extensions': ['.js'],
+ 'import/resolver': {
+ node: {
+ extensions: ['.js', '.json'],
+ },
+ },
+ },
+
+ rules: {
+ 'react/no-multi-comp': 1,
+ 'jsx-a11y/label-has-for': [2, {
+ required: {
+ some: ['nesting', 'id'],
+ },
+ }]
+ }
+};
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index 5944d43..4101683 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
- node-version: [14.x]
+ node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..25bf17f
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+18
\ No newline at end of file
diff --git a/Procfile b/Procfile
index e8f79ea..d3f1ac4 100644
--- a/Procfile
+++ b/Procfile
@@ -1 +1,3 @@
-web: npm start
\ No newline at end of file
+web: yarn start
+worker: yarn start:worker
+scheduler: yarn start:scheduler
\ No newline at end of file
diff --git a/eslint.js b/eslint.js
deleted file mode 100644
index f3950bf..0000000
--- a/eslint.js
+++ /dev/null
@@ -1,163 +0,0 @@
-module.exports = {
- root: true,
- parser: 'babel-eslint',
- plugins: ['jsx-a11y', 'react'],
-
- env: {
- browser: true,
- commonjs: true,
- es6: true,
- node: true,
- },
-
- parserOptions: {
- ecmaVersion: 6,
- sourceType: 'module',
- ecmaFeatures: {
- jsx: true,
- generators: true,
- experimentalObjectRestSpread: true,
- },
- },
-
- settings: {
- 'import/ignore': ['node_modules', '\\.(json|css|jpg|png|gif|eot|svg|ttf|woff|woff2|mp4|webm)$'],
- 'import/extensions': ['.js'],
- 'import/resolver': {
- node: {
- extensions: ['.js', '.json'],
- },
- },
- },
-
- rules: {
- // http://eslint.org/docs/rules/
- 'array-callback-return': 'warn',
- 'default-case': ['warn', { commentPattern: '^no default$' }],
- 'dot-location': ['warn', 'property'],
- eqeqeq: ['warn', 'allow-null'],
- 'guard-for-in': 'warn',
- indent: 'off',
- 'new-cap': ['warn', { newIsCap: true }],
- 'new-parens': 'warn',
- 'no-array-constructor': 'warn',
- 'no-caller': 'warn',
- 'no-cond-assign': ['warn', 'always'],
- 'no-const-assign': 'warn',
- 'no-control-regex': 'warn',
- 'no-delete-var': 'warn',
- 'no-dupe-args': 'warn',
- 'no-dupe-class-members': 'warn',
- 'no-dupe-keys': 'warn',
- 'no-duplicate-case': 'warn',
- 'no-empty-character-class': 'warn',
- 'no-empty-pattern': 'warn',
- 'no-eval': 'warn',
- 'no-ex-assign': 'warn',
- 'no-extend-native': 'warn',
- 'no-extra-bind': 'warn',
- 'no-extra-label': 'warn',
- 'no-fallthrough': 'warn',
- 'no-func-assign': 'warn',
- 'no-implied-eval': 'warn',
- 'no-invalid-regexp': 'warn',
- 'no-iterator': 'warn',
- 'no-label-var': 'warn',
- 'no-labels': ['warn', { allowLoop: false, allowSwitch: false }],
- 'no-lone-blocks': 'warn',
- 'no-loop-func': 'warn',
- 'no-mixed-spaces-and-tabs': 'warn',
- 'no-mixed-operators': [
- 'warn',
- {
- groups: [
- ['&', '|', '^', '~', '<<', '>>', '>>>'],
- ['==', '!=', '===', '!==', '>', '>=', '<', '<='],
- ['&&', '||'],
- ['in', 'instanceof'],
- ],
- allowSamePrecedence: false,
- },
- ],
- 'no-multi-str': 'warn',
- 'no-native-reassign': 'warn',
- 'no-negated-in-lhs': 'warn',
- 'no-new-func': 'warn',
- 'no-new-object': 'warn',
- 'no-new-symbol': 'warn',
- 'no-new-wrappers': 'warn',
- 'no-obj-calls': 'warn',
- 'no-octal': 'warn',
- 'no-octal-escape': 'warn',
- 'no-redeclare': 'warn',
- 'no-regex-spaces': 'warn',
- 'no-restricted-syntax': ['warn', 'LabeledStatement', 'WithStatement'],
- 'no-return-assign': 'warn',
- 'no-script-url': 'warn',
- 'no-self-assign': 'warn',
- 'no-self-compare': 'warn',
- 'no-sequences': 'warn',
- 'no-shadow-restricted-names': 'warn',
- 'no-sparse-arrays': 'warn',
- 'no-this-before-super': 'warn',
- 'no-throw-literal': 'warn',
- 'no-undef': 'warn',
- 'no-unexpected-multiline': 'warn',
- 'no-unneeded-ternary': 'warn',
- 'no-unreachable': 'warn',
- 'no-unused-expressions': 'warn',
- 'no-unused-labels': 'warn',
- 'no-unused-vars': ['warn', { vars: 'local', args: 'none' }],
- 'no-use-before-define': ['warn', 'nofunc'],
- 'no-useless-computed-key': 'warn',
- 'no-useless-concat': 'warn',
- 'no-useless-constructor': 'warn',
- 'no-useless-escape': 'off',
- 'no-useless-rename': [
- 'warn',
- {
- ignoreDestructuring: false,
- ignoreImport: false,
- ignoreExport: false,
- },
- ],
- 'no-with': 'warn',
- 'no-whitespace-before-property': 'warn',
- 'operator-assignment': ['warn', 'always'],
- 'operator-linebreak': 'off',
- 'prefer-const': 'warn',
- radix: 'warn',
- 'require-yield': 'warn',
- 'rest-spread-spacing': ['warn', 'never'],
- semi: ['warn', 'always'],
- strict: ['warn', 'never'],
- 'unicode-bom': ['warn', 'never'],
- 'use-isnan': 'warn',
- 'valid-typeof': 'warn',
-
- // https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
- 'react/jsx-equals-spacing': ['warn', 'never'],
- 'react/jsx-no-duplicate-props': ['warn', { ignoreCase: true }],
- 'react/jsx-no-undef': 'warn',
- 'react/jsx-pascal-case': [
- 'warn',
- {
- allowAllCaps: true,
- ignore: [],
- },
- ],
- 'react/jsx-uses-react': 'warn',
- 'react/jsx-uses-vars': 'warn',
- 'react/no-deprecated': 'warn',
- 'react/no-direct-mutation-state': 'warn',
- 'react/no-is-mounted': 'warn',
- 'react/react-in-jsx-scope': 'warn',
- 'react/require-render-return': 'warn',
-
- // https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
- 'jsx-a11y/aria-role': 'warn',
- // 'jsx-a11y/img-has-alt': 'warn',
- 'jsx-a11y/img-redundant-alt': 'warn',
- 'jsx-a11y/no-access-key': 'warn',
- },
-};
diff --git a/package.json b/package.json
index 60e6a1d..251ba89 100644
--- a/package.json
+++ b/package.json
@@ -11,92 +11,103 @@
"license": "SEE LICENSE IN LICENSE.md",
"scripts": {
"watch": "npm-run-all --parallel watch:*",
- "watch:server": "nodemon --watch src ./src/index.js",
"watch:client": "webpack --watch",
+ "watch:server": "nodemon --watch src ./src/index.js",
+ "watch:worker": "nodemon --watch src ./src/workers/index.js",
"build": "webpack",
"start": "node src/index.js",
+ "start:worker": "node src/workers/index.js",
"test": "mocha \"./src/**/tests/*.js\"",
"watch:test": "mocha --watch --recursive \"./src/**/tests/*.js\""
},
"engines": {
- "node": "14.x"
+ "node": "18.x"
},
"dependencies": {
- "@babel/runtime": "^7.7.4",
+ "@babel/runtime": "^7.22.6",
"@sentry/node": "^6.11.0",
"@sentry/tracing": "^6.11.0",
"body-parser": "^1.19.0",
+ "bullmq": "^2.4.0",
"connect-redis": "^3.4.2",
- "debug": "^3.2.6",
+ "debug": "^4.3.3",
"envify": "^4.1.0",
"es6-promise": "^3.2.1",
"express": "^4.17.1",
+ "express-async-handler": "^1.2.0",
"express-session": "^1.17.0",
"grant-express": "^4.6.4",
"helmet": "^3.21.2",
"history": "^4.10.1",
"is-docker": "^2.0.0",
"lodash": "^4.17.15",
+ "pixelarticons": "^1.7.0",
"raven-js": "3.26.3",
"react-markdown": "^2.5.0",
"redis": "^2.8.0",
"sass": "^1.38.0",
+ "serialize-javascript": "^6.0.0",
"stringify": "^5.1.0",
- "tumblr.js": "https://github.com/cubeghost/tumblr.js",
- "winston": "^3.2.1"
+ "tumblr.js": "4.0.1",
+ "uuid": "^8.3.2",
+ "winston": "^3.2.1",
+ "ws": "^8.4.0"
},
"devDependencies": {
- "@babel/core": "^7.7.4",
- "@babel/plugin-transform-runtime": "^7.7.4",
- "@babel/preset-env": "^7.7.4",
- "@babel/preset-react": "^7.7.4",
+ "@babel/core": "^7.22.8",
+ "@babel/eslint-parser": "^7.22.7",
+ "@babel/plugin-transform-runtime": "^7.22.7",
+ "@babel/preset-env": "^7.22.7",
+ "@babel/preset-react": "^7.22.5",
+ "@bull-board/express": "3.11.1",
+ "@react-spring/web": "9.6.x",
+ "@reduxjs/toolkit": "^1.9.5",
"autoprefixer": "9.0.0",
- "babel-eslint": "8.2.6",
- "babel-loader": "^8.0.6",
+ "babel-eslint": "^10.1.0",
+ "babel-loader": "^8.x",
"babel-plugin-transform-runtime": "^6.23.0",
- "case-sensitive-paths-webpack-plugin": "2.1.2",
+ "case-sensitive-paths-webpack-plugin": "^2.4.0",
"chai": "^4.2.0",
- "class-autobind": "^0.1.4",
- "classnames": "^2.2.6",
"clean-webpack-plugin": "^0.1.19",
- "css-loader": "1.0.0",
+ "clsx": "^1.1.1",
+ "css-loader": "^6.10.0",
"dotenv": "^2.0.0",
- "eslint": "^5.16.0",
+ "esbuild-loader": "^4.0.3",
+ "eslint": "^8.44.0",
"eslint-formatter-pretty": "1.3.0",
"eslint-loader": "2.0.0",
- "eslint-plugin-jsx-a11y": "6.1.1",
- "eslint-plugin-react": "7.10.0",
- "file-loader": "^1.1.11",
- "find-cache-dir": "^2.1.0",
- "friendly-errors-webpack-plugin": "1.7.0",
- "html-webpack-plugin": "3.2.0",
- "mini-css-extract-plugin": "0.4.1",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-react": "^7.32.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-webpack-plugin": "^4.0.1",
+ "html-webpack-plugin": "^5.6.0",
+ "mini-css-extract-plugin": "^2.8.0",
"mocha": "^5.2.0",
"nodemon": "^1.19.4",
"npm-run-all": "^4.1.5",
- "postcss-loader": "2.1.6",
+ "postcss-loader": "^8.1.0",
"prelude-extension": "^0.1.0",
"prelude-ls": "^1.1.2",
- "raw-loader": "^0.5.1",
- "react": "^16.12.0",
- "react-dom": "^16.12.0",
- "react-redux": "^5.1.2",
- "react-router": "^4.3.1",
- "react-router-dom": "^4.3.1",
- "react-select": "^2.4.4",
- "redux": "^3.7.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-redux": "^7",
+ "react-router": "^5",
+ "react-router-dom": "^5",
+ "react-select": "^5.7.3",
+ "redux": "^4",
"redux-thunk": "^2.3.0",
- "sass-loader": "7.3.1",
- "simple-progress-webpack-plugin": "1.1.2",
+ "sass-loader": "^14.1.1",
+ "simple-progress-webpack-plugin": "^2.0.0",
"style-loader": "0.21.0",
- "terser-webpack-plugin": "4",
"tether": "^1.4.7",
- "uglifyjs-webpack-plugin": "1.2.7",
- "webpack": "4",
- "webpack-cli": "3.1.0",
+ "webpack": "^5.90.3",
+ "webpack-cli": "^5.1.4",
"whatwg-fetch": "^1.0.0"
},
"eslintConfig": {
"extends": "./eslint.js"
+ },
+ "optionalDependencies": {
+ "bufferutil": "^4.0.5"
}
-}
+}
\ No newline at end of file
diff --git a/src/assets/postTypes.svg b/src/assets/postTypes.svg
new file mode 100644
index 0000000..1dc9787
--- /dev/null
+++ b/src/assets/postTypes.svg
@@ -0,0 +1,48 @@
+
+
\ No newline at end of file
diff --git a/src/assets/tinygradient.svg b/src/assets/tinygradient.svg
deleted file mode 100644
index 6b9283f..0000000
--- a/src/assets/tinygradient.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/client.js b/src/client.js
deleted file mode 100644
index 760c8da..0000000
--- a/src/client.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { Provider } from 'react-redux';
-import { BrowserRouter } from 'react-router-dom';
-
-import initialState from './client/state/initial';
-import configureStore from './client/state/store';
-
-import App from './client/app';
-
-import './scss/style.scss';
-
-const store = configureStore(initialState);
-
-const ROOT = (
-
-
-
-
-
-);
-
-ReactDOM.render(ROOT, document.querySelector('#react-root'));
diff --git a/src/client/2021-12-26-notice.md b/src/client/2021-12-26-notice.md
deleted file mode 100644
index 09a0066..0000000
--- a/src/client/2021-12-26-notice.md
+++ /dev/null
@@ -1,4 +0,0 @@
-### 2021/12/26
-If you are here to replace tags affected by the recent [iOS app update](https://wip.tumblr.com/post/671184848292118528/an-update-on-the-tumblr-ios-app), I personally would recommend leaving your tags as-is for now until we know more. As far as I know, the current workaround is intended to be temporary.
-
-If you find you can't access posts with tags that seem innocuous, or for other reasons shouldn't be "banned", please [report that as a bug to Tumblr](https://www.tumblr.com/support).
\ No newline at end of file
diff --git a/src/client/App.js b/src/client/App.js
new file mode 100644
index 0000000..c70e9cb
--- /dev/null
+++ b/src/client/App.js
@@ -0,0 +1,86 @@
+import React, { useEffect } from 'react';
+import { Route, Link } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { getUser, websocketConnect } from './state/actions';
+
+import Home from './Home';
+import Replacer from './components/Replacer';
+import Help from './Help';
+import Privacy from './Privacy';
+import DarkModeToggle from './components/DarkModeToggle';
+
+const replaceHash = () => {
+ const location = window.location;
+ if (location.hash && location.hash === '#_=_') {
+ location.replace(location.origin + location.pathname);
+ }
+};
+
+const Errors = () => {
+ const errors = useSelector(state => state.errors);
+ return errors.map((error, i) => (
+
+ {JSON.stringify(error)}
+
+ ));
+}
+
+const App = () => {
+ const dispatch = useDispatch();
+
+ const isAuthed = useSelector(state => Boolean(state.tumblr.username));
+
+ useEffect(() => {
+ replaceHash();
+ dispatch(websocketConnect());
+ dispatch(getUser());
+ }, [dispatch]);
+
+ return (
+
+
+
+ tag replacer
+
+
+
+
+
+
+
+
+
+
{
+ if (isAuthed) {
+ return ;
+ } else {
+ return ;
+ }
+ }}
+ />
+
+
+
+ );
+};
+
+export default React.memo(App);
diff --git a/src/client/help.js b/src/client/Help.js
similarity index 91%
rename from src/client/help.js
rename to src/client/Help.js
index fa29e11..c13de79 100644
--- a/src/client/help.js
+++ b/src/client/Help.js
@@ -14,4 +14,4 @@ const Help = () => (
);
-export default Help;
+export default React.memo(Help);
diff --git a/src/client/Home.js b/src/client/Home.js
new file mode 100644
index 0000000..c06e32a
--- /dev/null
+++ b/src/client/Home.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+
+import ConnectButton from './components/ConnectButton';
+
+const Home = () => {
+ const isLoading = useSelector(state => state.tumblr.loading);
+
+ if (isLoading) {
+ return (
+
+
loading
+

+
+ );
+ } else {
+ return ;
+ }
+};
+
+export default React.memo(Home);
diff --git a/src/client/privacy.js b/src/client/Privacy.js
similarity index 91%
rename from src/client/privacy.js
rename to src/client/Privacy.js
index 0745de0..8d82389 100644
--- a/src/client/privacy.js
+++ b/src/client/Privacy.js
@@ -14,4 +14,4 @@ const Privacy = () => (
);
-export default Privacy;
+export default React.memo(Privacy);
diff --git a/src/client/Replacer.js b/src/client/Replacer.js
new file mode 100644
index 0000000..c662ba5
--- /dev/null
+++ b/src/client/Replacer.js
@@ -0,0 +1,163 @@
+import React, { useCallback, useRef } from 'react';
+import { useSelector, useDispatch, shallowEqual } from 'react-redux';
+import clsx from 'clsx';
+
+import Options from './Options';
+import Results from './components/Results';
+import TagInput from './components/TagInput';
+import BlogSelect from './components/BlogSelect';
+
+import { formatTags } from './util';
+import * as actions from './state/actions';
+
+const LOADING = 'https://media.giphy.com/media/l3fQv3YSQZwlTTbC8/200.gif';
+
+/* eslint-disable react/no-multi-comp */
+const Replaced = () => {
+ const options = useSelector(state => state.options, shallowEqual);
+ const find = useSelector(state => state.form.find);
+ const replace = useSelector(state => state.form.replace);
+
+ const replaced = useSelector(state => state.tumblr.replaced, shallowEqual);
+
+ const totalReplaced = replaced?.length;
+
+ if (totalReplaced === 0) return null;
+
+ if (replace.length === 0 && options.allowDelete) {
+ return (
+
+ deleted {formatTags(find)} for {totalReplaced} posts
+
+ );
+ } else {
+ return (
+
+ replaced {formatTags(find)} with
+ {formatTags(replace)} for
+ {totalReplaced} posts
+
+ );
+ }
+
+};
+
+const Replacer = () => {
+ const dispatch = useDispatch();
+
+ const options = useSelector(state => state.options, shallowEqual);
+
+ const blog = useSelector(state => state.form.blog);
+ const find = useSelector(state => state.form.find);
+ const replace = useSelector(state => state.form.replace);
+
+ const foundPosts = useSelector(state => state.tumblr.posts?.length > 0);
+ const isLoading = useSelector(state => state.loading);
+ // const [replaced, setReplaced] = useState([]); // TODO why isnt this in redux
+
+ const replaceInputRef = useRef();
+
+ const disableBlog = !!foundPosts;
+ const disableFind = !blog || !!foundPosts;
+ const disableReplace = !foundPosts;
+
+ const disableFindButton = find.length === 0 || disableFind;
+ const disableReplaceButton = replace.length === 0 && !options.allowDelete;
+ const deleteMode = replace.length === 0 && options.allowDelete;
+
+ const handleFind = useCallback((event) => {
+ if (event) event.preventDefault();
+
+ dispatch(actions.find())
+ .then(() => {
+ replaceInputRef.current?.focus();
+ });
+ }, [dispatch])
+
+ const handleReplace = useCallback((event) => {
+ if (event) event.preventDefault();
+
+ dispatch(actions.replace())
+ // .then(action => setReplaced(action.response));
+ }, [dispatch]);
+
+ const handleReset = useCallback((event) => {
+ if (event) event.preventDefault();
+
+ dispatch(actions.reset());
+ // setReplaced([]);
+ }, [dispatch]);
+
+ return (
+
+ {isLoading && (
+
+
loading
+

+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {foundPosts && (
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default React.memo(Replacer);
diff --git a/src/client/api.js b/src/client/api.js
new file mode 100644
index 0000000..b10f4e9
--- /dev/null
+++ b/src/client/api.js
@@ -0,0 +1,22 @@
+export async function apiFetch(method, path, body) {
+ const config = {
+ method: method,
+ credentials: 'include'
+ };
+ if (body) {
+ config.headers = { 'Content-Type': 'application/json' };
+ config.body = JSON.stringify(body);
+ }
+
+ const response = await fetch(`/api${path}`, config)
+
+ if (!response.ok) {
+ const error = new Error(response.statusText);
+ error.status = response.status;
+ error.statusText = response.statusText;
+ error.body = await response.json();
+ throw error;
+ }
+
+ return await response.json();
+}
diff --git a/src/client/app.js b/src/client/app.js
deleted file mode 100644
index 48895b9..0000000
--- a/src/client/app.js
+++ /dev/null
@@ -1,107 +0,0 @@
-import React, { Component } from 'react';
-import { withRouter } from 'react-router';
-import { Route, Link } from 'react-router-dom';
-import { connect } from 'react-redux';
-import ReactMarkdown from 'react-markdown';
-
-import { getUser } from './state/actions';
-
-import Home from './home';
-import Replacer from './replacer';
-import Help from './help';
-import Privacy from './privacy';
-
-import noticeMarkdown from './2021-12-26-notice.md';
-
-const mapStateToProps = state => ({
- authed: !!state.tumblr.username,
- loading: state.loading,
- errors: state.errors,
-});
-
-const mapDispatchToProps = dispatch => ({
- getUser: () => dispatch(getUser()),
-});
-
-class App extends Component {
-
- componentDidMount() {
- this.replaceHash();
- this.props.getUser();
- }
-
- // remove the #_=_ that comes back from tumblr oauth
- replaceHash() {
- const { location, history } = this.props;
- if (location.hash && location.hash === '#_=_') {
- history.replace(location.pathname);
- }
- }
-
- // render
-
- renderErrors() {
- return this.props.errors.map((error, i) => {
- if (error.body.message === 'No user session') return null;
- return (
-
- {JSON.stringify(error)}
-
- );
- });
- }
-
- render() {
- return (
-
-
-
- tag replacer
-
-
-
-
-
-
-
-
-
- {this.renderErrors()}
-
-
-
-
- ;
- } else {
- return ;
- }
- }.bind(this)}
- />
-
-
-
-
- );
- }
-};
-
-export default withRouter(connect(mapStateToProps, mapDispatchToProps)(App));
diff --git a/src/client/components/BlogSelect.js b/src/client/components/BlogSelect.js
new file mode 100644
index 0000000..5a29f82
--- /dev/null
+++ b/src/client/components/BlogSelect.js
@@ -0,0 +1,42 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import { useSelector, useDispatch } from 'react-redux';
+import Select from 'react-select';
+
+import { selectStyles, selectTheme } from './Select';
+import { setFormValue } from '../state/actions';
+
+const BlogSelect = ({ disabled }) => {
+ const dispatch = useDispatch();
+ const blogs = useSelector(state => state.tumblr.blogs);
+ const value = useSelector(state => state.form.blog);
+
+ const onChange = useCallback(select => (
+ dispatch(setFormValue('blog', select.value))
+ ), [dispatch]);
+
+ return (
+