├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── calibreapp-image-actions.yml │ ├── codeql-analysis.yml │ ├── dependabotAutoMerge.yml │ ├── dockerimage.yml │ └── nodejs.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── settings.json └── snippets.code-snippets ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ ├── file-mock.js ├── gatsby.js ├── style-mock.js └── svg-mock.js ├── content ├── meta │ └── config.js ├── pages │ ├── resources │ │ └── index.mdx │ ├── success │ │ └── index.mdx │ └── uses │ │ └── index.mdx ├── parts │ └── author.mdx └── posts │ ├── 2018 │ ├── 2018-11-10--react-tutorial-adding-typescript │ │ ├── index.mdx │ │ └── react-logo.png │ └── 2018-11-20--javascript-copyright-date │ │ ├── 2019.jpg │ │ └── index.mdx │ ├── 2019 │ └── 2019-04-30--change-specflow-build │ │ ├── index.mdx │ │ └── sf-logo.png │ ├── 2020 │ ├── 2020-02-08--gatsby-change-from-md-to-mdx │ │ ├── gatsby-mdx.png │ │ └── index.mdx │ ├── 2020-02-11--gatsby-create-published-filter-for-posts │ │ ├── gatsby-blue-green.png │ │ └── index.mdx │ ├── 2020-05-21--gatsby-create-an-audience-with-mailchimp │ │ ├── finished_form.png │ │ ├── index.mdx │ │ └── mail.jpg │ ├── 2020-06-01--compress-your-images-with-tinify │ │ ├── image.jpg │ │ └── index.mdx │ ├── 2020-06-30--add-linting-to-create-react-app │ │ ├── image.jpg │ │ └── index.mdx │ └── 2020-07-10--debug-gatsby-simulator │ │ ├── index.mdx │ │ └── laptop.jpg │ └── 2021 │ ├── 2021-01-08--gatsby-error-monitoring-with-sentry │ ├── error_image.jpg │ └── index.mdx │ ├── 2021-01-15--react-netlify-client-side-routing │ ├── computer.jpg │ └── index.mdx │ ├── 2021-01-21--ssh-synology-nas │ ├── index.mdx │ └── synology.png │ ├── 2021-01-25--portainer-docker-nas │ ├── index.mdx │ └── shipyard.jpg │ ├── 2021-01-31--pihole-docker-nas │ ├── index.mdx │ └── pi-hole.png │ ├── 2021-02-05--calibre-library-docker-nas │ ├── index.mdx │ └── library.jpg │ └── 2021-02-19--watchtower-docker-nas │ ├── index.mdx │ └── watchtower.jpg ├── coverage ├── badge-branches.svg ├── badge-functions.svg ├── badge-lines.svg └── badge-statements.svg ├── cypress.json ├── cypress ├── e2e │ ├── a11y.test.js │ ├── index.test.js │ └── pages.test.js ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ └── index.js ├── docker-compose.yml ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── jest-preprocess.js ├── jest.config.js ├── loadershim.js ├── netlify.toml ├── package.json ├── postcss.config.js ├── scripts └── generate-app-icons.sh ├── src ├── components │ ├── Article │ │ ├── Article.js │ │ ├── Bodytext.js │ │ ├── Headline.js │ │ ├── SubHeadline.js │ │ └── index.js │ ├── AsyncComponent │ │ ├── AsyncComponent.js │ │ └── index.js │ ├── Blog │ │ ├── Blog.js │ │ ├── Item.js │ │ └── index.js │ ├── Contact │ │ ├── Contact.js │ │ └── index.js │ ├── ErrorBoundary │ │ ├── ErrorBoundary.js │ │ └── index.js │ ├── Footer │ │ ├── Footer.js │ │ └── index.js │ ├── Header │ │ ├── Header.js │ │ └── index.js │ ├── Hero │ │ ├── Hero.js │ │ └── index.js │ ├── List │ │ ├── List.js │ │ └── index.js │ ├── Menu │ │ ├── Expand.js │ │ ├── Item.js │ │ ├── Menu.js │ │ └── index.js │ ├── Page │ │ ├── Page.js │ │ └── index.js │ ├── Post │ │ ├── Author.js │ │ ├── Meta.js │ │ ├── NextPrev.js │ │ ├── Post.js │ │ ├── Share.js │ │ └── index.js │ ├── Project │ │ ├── Project.js │ │ └── index.js │ ├── Search │ │ ├── Hit.js │ │ ├── Search.js │ │ └── index.js │ ├── Seo │ │ ├── Seo.js │ │ └── index.js │ ├── Skill │ │ ├── Skill.js │ │ ├── SkillCard.js │ │ └── index.js │ ├── Social │ │ ├── Social.js │ │ └── index.js │ ├── Subscribe │ │ ├── Subscribe.js │ │ └── index.js │ ├── Work │ │ ├── Education.js │ │ ├── Volunteer.js │ │ ├── Work.js │ │ └── index.js │ └── __tests__ │ │ ├── __snapshots__ │ │ ├── article.spec.js.snap │ │ ├── education.spec.js.snap │ │ ├── footer.spec.js.snap │ │ ├── skill.spec.js.snap │ │ ├── social.spec.js.snap │ │ └── volunteer.spec.js.snap │ │ ├── article.spec.js │ │ ├── education.spec.js │ │ ├── footer.spec.js │ │ ├── skill.spec.js │ │ ├── social.spec.js │ │ └── volunteer.spec.js ├── data │ └── project.json ├── html.js ├── images │ ├── app-icons │ │ ├── apple-icon.png │ │ └── icon.png │ ├── jpg │ │ └── avatar.jpg │ ├── png │ │ ├── hero-background.png │ │ └── mesh.png │ ├── project │ │ ├── cardshuffling.png │ │ ├── chrisottodev.png │ │ ├── fitnesseformat.png │ │ ├── library.png │ │ └── vscodefitnesse.png │ └── svg-icons │ │ ├── algolia-full.svg │ │ ├── algolia.svg │ │ ├── email.svg │ │ ├── facebook.svg │ │ └── search-by-algolia.svg ├── layouts │ └── index.js ├── pages │ ├── 404.js │ ├── about.js │ ├── contact.js │ ├── project.js │ ├── search.js │ └── tag.js ├── templates │ ├── PageTemplate.js │ ├── PostTemplate.js │ ├── TagTemplate.js │ └── index.js ├── theme │ └── theme.json └── utils │ ├── algolia.js │ └── helpers.js ├── static ├── _headers ├── favicon.ico └── icons │ ├── apple-icon-114x114.png │ ├── apple-icon-120x120.png │ ├── apple-icon-144x144.png │ ├── apple-icon-152x152.png │ ├── apple-icon-180x180.png │ ├── apple-icon-57x57.png │ ├── apple-icon-60x60.png │ ├── apple-icon-72x72.png │ ├── apple-icon-76x76.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── icon-144x144.png │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ ├── icon-48x48.png │ ├── icon-512x512.png │ └── icon-96x96.png ├── util ├── compressImages.js ├── newPost.js └── registry.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-proposal-optional-chaining"], 3 | "presets": [ 4 | [ 5 | "babel-preset-gatsby", 6 | { 7 | "targets": { 8 | "browsers": [">0.25%", "not dead"] 9 | } 10 | } 11 | ] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/* 2 | static/* 3 | .cache/* 4 | src/components/__tests__/* -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "google", 5 | "prettier", 6 | "prettier/react", 7 | "eslint:recommended", 8 | "plugin:react/recommended" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 2016, 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "plugins": ["prettier", "react", "jsx-a11y", "import", "graphql"], 18 | "env": { 19 | "browser": true, 20 | "es6": true, 21 | "node": true 22 | }, 23 | "rules": { 24 | "arrow-body-style": "off", 25 | "camelcase": "warn", 26 | "func-names": "off", 27 | "global-require": "warn", 28 | "guard-for-in": "off", 29 | "import/no-dynamic-require": "warn", 30 | "import/no-extraneous-dependencies": "off", 31 | "no-console": "off", 32 | "no-invalid-this": "off", 33 | "no-multi-assign": "off", 34 | "no-param-reassign": "warn", 35 | "no-plusplus": "off", 36 | "no-shadow": "warn", 37 | "no-underscore-dangle": "warn", 38 | "no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], 39 | "no-nested-ternary": "off", 40 | "no-unused-vars": "warn", 41 | "prefer-destructuring": "off", 42 | "prettier/prettier": ["error"], 43 | "react/jsx-uses-vars": "error", 44 | "require-jsdoc": "off" 45 | }, 46 | "overrides": [ 47 | { 48 | "files": ["*.spec.js", "*.integration.js"], 49 | "rules": { 50 | "no-unused-expressions": "off" 51 | } 52 | } 53 | ], 54 | "settings": { 55 | "react": { 56 | "pragma": "React", 57 | "version": "detect" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '11:00' 8 | open-pull-requests-limit: 50 9 | target-branch: dependencies 10 | labels: 11 | - dependencies 12 | -------------------------------------------------------------------------------- /.github/workflows/calibreapp-image-actions.yml: -------------------------------------------------------------------------------- 1 | name: Compress images 2 | on: pull_request 3 | jobs: 4 | build: 5 | name: calibreapp/image-actions 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout Repo 9 | uses: actions/checkout@master 10 | - name: Compress Images 11 | uses: calibreapp/image-actions@master 12 | with: 13 | githubToken: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master, dependencies] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 21 * * 4' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v1 38 | with: 39 | languages: ${{ matrix.language }} 40 | # If you wish to specify custom queries, you can do so here or in a config file. 41 | # By default, queries listed here will override any specified in a config file. 42 | # Prefix the list here with "+" to use these queries and those in the config file. 43 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/dependabotAutoMerge.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | on: 3 | pull_request: 4 | jobs: 5 | auto-merge: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 10 | with: 11 | target: minor 12 | github-token: ${{ secrets.PAT }} 13 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | env: 11 | ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} 12 | ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} 13 | ALGOLIA_INDEX_NAME: ${{ secrets.ALGOLIA_INDEX_NAME }} 14 | ALGOLIA_SEARCH_ONLY_API_KEY: ${{ secrets.ALGOLIA_SEARCH_ONLY_API_KEY }} 15 | FB_APP_ID: ${{ secrets.FB_APP_ID }} 16 | GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID}} 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Publish to Registry 20 | uses: elgohr/Publish-Docker-Github-Action@master 21 | with: 22 | name: ${{ github.repository }}/chrisottodev 23 | username: chrisotto6 24 | password: ${{ secrets.AUTH }} 25 | registry: docker.pkg.github.com 26 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Build, Lint and Test CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | env: 7 | ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }} 8 | ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} 9 | ALGOLIA_INDEX_NAME: ${{ secrets.ALGOLIA_INDEX_NAME }} 10 | ALGOLIA_SEARCH_ONLY_API_KEY: ${{ secrets.ALGOLIA_SEARCH_ONLY_API_KEY }} 11 | FB_APP_ID: ${{ secrets.FB_APP_ID }} 12 | GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID}} 13 | strategy: 14 | matrix: 15 | node-version: [12.0.0] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install And Lint 23 | run: yarn install && npm run lint 24 | env: 25 | CI: true 26 | - name: Build And Test 27 | run: npm run build:local && npm test && npm run e2e 28 | env: 29 | CI: true 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # env 2 | .env.development 3 | 4 | # Cypress 5 | cypress/screenshots/* 6 | coverage/lcov-report 7 | coverage/*.json 8 | coverage/*.xml 9 | coverage/*.info 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (http://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Typescript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | .cache/ 68 | .DS_Store 69 | public/ 70 | yarn-error.log 71 | 72 | # editors 73 | .idea/ 74 | 75 | # other 76 | report/ 77 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 100, 4 | "endOfLine": "lf", 5 | "semi": false, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "es5" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | // Place your chrisottodev workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and 3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope 4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is 5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are: 6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. 7 | // Placeholders with the same ids are connected. 8 | // Example: 9 | // "Print to console": { 10 | // "scope": "javascript,typescript", 11 | // "prefix": "log", 12 | // "body": [ 13 | // "console.log('$1');", 14 | // "$2" 15 | // ], 16 | // "description": "Log output to console" 17 | // } 18 | "RC": { 19 | "scope": "javascript,javascriptreact", 20 | "prefix": "rc", 21 | "body": [ 22 | "import React from 'react'", 23 | "import PropTypes from 'prop-types'", 24 | "", 25 | "const ${1} = (props) => {", 26 | " const {} = props", 27 | "", 28 | " return (", 29 | " ", 30 | " {/* --- STYLES --- */}", 31 | " ", 34 | " ", 35 | " )", 36 | "}", 37 | "", 38 | "${1}.propTypes = {}", 39 | "", 40 | "export default ${1}", 41 | "" 42 | ], 43 | "description": "Create new React Component for this" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at greglobinski@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | # Also exposing VSCode debug ports 4 | EXPOSE 8000 9929 9230 5 | 6 | RUN \ 7 | apk add --no-cache python make g++ && \ 8 | apk add vips-dev fftw-dev --update-cache \ 9 | --repository http://dl-3.alpinelinux.org/alpine/edge/community \ 10 | --repository http://dl-3.alpinelinux.org/alpine/edge/main \ 11 | && rm -fR /var/cache/apk/* 12 | 13 | RUN npm install -g gatsby-cli 14 | 15 | WORKDIR /app 16 | COPY ./package.json . 17 | RUN yarn install && yarn cache clean 18 | COPY . . 19 | CMD ["yarn", "develop", "-H", "0.0.0.0" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Chris Otto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chris Otto's Blog 2 | 3 | ## Build and Deploy 4 | 5 | ![Build Status](https://github.com/chrisotto6/chrisottodev/workflows/Build,%20Lint%20and%20Test%20CI/badge.svg) [![Netlify Status](https://api.netlify.com/api/v1/badges/2e067759-e5e5-4337-9e22-371754eb3d3e/deploy-status)](https://app.netlify.com/sites/gatsby-otto/deploys) 6 | 7 | ## Coverage 8 | 9 | [![Coverage](./coverage/badge-lines.svg)](https://github.com/chrisotto6/gatsby-starter) [![Coverage](./coverage/badge-branches.svg)](https://github.com/chrisotto6/gatsby-starter) [![Coverage](./coverage/badge-functions.svg)](https://github.com/chrisotto6/gatsby-starter) [![Coverage](./coverage/badge-statements.svg)](https://github.com/chrisotto6/gatsby-starter) 10 | 11 | ## Getting Started 12 | 13 | Clone the repository and run: 14 | 15 | ```text 16 | yarn install 17 | ``` 18 | 19 | To run the local develop build run: 20 | 21 | ```text 22 | gatsby develop 23 | ``` 24 | 25 | To run the production build and serve locally run: 26 | 27 | ```text 28 | yarn build:local && gatsby serve 29 | ``` 30 | -------------------------------------------------------------------------------- /__mocks__/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' 2 | -------------------------------------------------------------------------------- /__mocks__/gatsby.js: -------------------------------------------------------------------------------- 1 | const React = require('react') 2 | 3 | const gatsby = jest.requireActual('gatsby') 4 | 5 | module.exports = { 6 | ...gatsby, 7 | graphql: jest.fn(), 8 | Link: jest.fn().mockImplementation( 9 | // these props are invalid for an `a` tag 10 | ({ 11 | activeClassName, 12 | activeStyle, 13 | getProps, 14 | innerRef, 15 | partiallyActive, 16 | ref, 17 | replace, 18 | to, 19 | ...rest 20 | }) => 21 | React.createElement('a', { 22 | ...rest, 23 | href: to, 24 | }) 25 | ), 26 | StaticQuery: jest.fn(), 27 | useStaticQuery: jest.fn(), 28 | } 29 | -------------------------------------------------------------------------------- /__mocks__/style-mock.js: -------------------------------------------------------------------------------- 1 | // __mocks__/styleMock.js 2 | 3 | module.exports = {} 4 | -------------------------------------------------------------------------------- /__mocks__/svg-mock.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = 'div' 3 | -------------------------------------------------------------------------------- /content/meta/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteTitle: 'Chris Otto | Father, Developer, Test Engineer', // 3 | siteDescription: 'Thoughts and projects', 4 | siteUrl: 'https://chrisotto.dev', 5 | siteLanguage: 'en', 6 | 7 | /* author */ 8 | authorName: 'Chris Otto', 9 | authorTwitterAccount: 'wisco_cmo', 10 | 11 | /* info */ 12 | headerTitle: 'Chris Otto', 13 | headerSubTitle: 'Developer, Test Engineer', 14 | 15 | /* manifest.json */ 16 | manifestName: 'Chris Otto | Father, Developer, Test Engineer', 17 | manifestShortName: 'Chris Otto', // max 12 characters 18 | manifestStartUrl: '/index.html', 19 | manifestBackgroundColor: 'white', 20 | manifestThemeColor: '#666', 21 | manifestDisplay: 'standalone', 22 | 23 | // gravatar 24 | gravatarImgMd5: '', 25 | 26 | // social 27 | authorSocialLinks: [ 28 | { name: 'dev', url: 'https://dev.to/chrisotto' }, 29 | { name: 'github', url: 'https://github.com/chrisotto6' }, 30 | { name: 'instagram', url: 'https://instagram.com/wisco_cmo' }, 31 | { name: 'linkedin', url: 'https://www.linkedin.com/in/ottochristopher/' }, 32 | { name: 'twitter', url: 'https://twitter.com/wisco_cmo' }, 33 | ], 34 | } 35 | -------------------------------------------------------------------------------- /content/pages/success/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Success 3 | published: true 4 | --- 5 | 6 | Thank you for reaching out! <br /> 7 | I will answer your message as soon as possible. 8 | -------------------------------------------------------------------------------- /content/pages/uses/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Uses 3 | published: true 4 | --- 5 | 6 | # Uses 7 | 8 | What I `use` for my developer setup, gear, software, configs, etc. 9 | 10 | ## Computers 11 | 12 | ### Desktop 13 | 14 | #### Core 15 | 16 | - Case: [NZXT S340 Mid Tower](https://www.amazon.com/gp/product/B00NGMIBXC) 17 | - Motherboard: [ROG Maximus IX Code](https://www.amazon.com/gp/product/B01NGTRXOOASUS) 18 | - Processor: [Intel Core i7-7700K](https://www.amazon.com/gp/product/B01MXSI216) 19 | - CPU Cooler: [NZXT Kraken X61 280mm Liquid Cooling System](https://www.amazon.com/gp/product/B00L0YLJJG) 20 | - RAM: 32 GB [Corsair Vengeance LPX DDR4](https://www.amazon.com/gp/product/B0134EW7G8) 21 | - Boot Drive: [MyDigitalSSD BPX M.2 PCIE 480GB](https://www.amazon.com/gp/product/B01MDRUXNZ) 22 | - Power Supply: [EVGA 500W](https://www.amazon.com/gp/product/B00H33SFJU) 23 | - Graphics Card: [EVGA GeForce GTX 970](https://www.amazon.com/gp/product/B00U2ON9B6) 24 | 25 | #### Peripherals 26 | 27 | - Monitor: 2x - [Acer 23.8 in IPS](https://www.amazon.com/gp/product/B01LY3DB9J) 28 | - Monitor Mount: [VIVO Dual Monitor Desk Mount](https://www.amazon.com/gp/product/B009S750LA) 29 | - Headset: [Astro A50](https://www.amazon.com/gp/product/B01G3WBCQY) 30 | - Mouse: [Razer DeathAdder Elite](https://www.amazon.com/gp/product/B01LXC1QL0) 31 | - Keyboard: [Corsair K55](https://www.amazon.com/gp/product/B01M4LIKLI) 32 | - Webcam: [Logitech C920](https://www.amazon.com/gp/product/B006JH8T3S) 33 | 34 | ### Laptop 35 | 36 | #### Core 37 | 38 | - 2015 MacBook Pro 39 | - Processor: 2.5 GHz Quad-core Intel Core i7 40 | - Memory: 16 GB 1600 MHz DDR3L 41 | - Storage: 512GB Flash Storage 42 | 43 | #### Peripherals 44 | 45 | - Mouse: [Logitech MX Master](https://www.amazon.com/gp/product/B076VKQVK3) 46 | - Lapdesk: [LapGear Home Office Lap Desk](https://www.amazon.com/gp/product/B01C785EJ4) 47 | - Stand: [Rain Design mStand Laptop Stand](https://www.amazon.com/gp/product/B000OOYECC) 48 | -------------------------------------------------------------------------------- /content/parts/author.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: author 3 | --- 4 | 5 | **Chris Otto** is a test engineer by day and developer by night. My hobbies include side projects, video games and building things with my hands. 6 | -------------------------------------------------------------------------------- /content/posts/2018/2018-11-10--react-tutorial-adding-typescript/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: React Tutorial - Add Typescript 3 | cover: react-logo.png 4 | author: Chris Otto 5 | tags: ['react', 'typescript'] 6 | published: true 7 | --- 8 | 9 | This week I was going through the React [tutorial](https://reactjs.org/tutorial/tutorial.html). While going through each phase of the tutorial; going over state, JSX and React Components I wanted to be writing it in Typescript. I'll go through the tutorial code of what I had to change in order to get it working. 10 | 11 | - Use node to install the typescript dependencies we need: 12 | 13 | ```bash 14 | npm install --save typescript @types/node @types/react @types/react-dom @types/jest 15 | ``` 16 | 17 | - Change the function definition of Square, pass (props: any) instead of just (props): 18 | 19 | ```js 20 | function Square(props: any) {} 21 | ``` 22 | 23 | - Modify the Board and Game Component to accept any in the Component definition: 24 | 25 | ```js 26 | class Board extends React.Component<any, any> {} 27 | 28 | class Game extends React.Component<any, any> {} 29 | ``` 30 | 31 | - Change the file type to be .tsx instead of .js 32 | 33 | Just like that, with a few package installs and a few code changes you're able to compile the project using typescript instead of normal JS. You will probably want to make other modifications to take advantage of other useful parts of typescript. 34 | -------------------------------------------------------------------------------- /content/posts/2018/2018-11-10--react-tutorial-adding-typescript/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2018/2018-11-10--react-tutorial-adding-typescript/react-logo.png -------------------------------------------------------------------------------- /content/posts/2018/2018-11-20--javascript-copyright-date/2019.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2018/2018-11-20--javascript-copyright-date/2019.jpg -------------------------------------------------------------------------------- /content/posts/2018/2018-11-20--javascript-copyright-date/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dynamically Set Footer Copyright Date 3 | cover: 2019.jpg 4 | author: Chris Otto 5 | tags: ['javascript'] 6 | published: true 7 | --- 8 | 9 | With 2018 winding down I found myself thinking that I will need to leave a note to update the footer on my site to change the year to 2019. Having to come back year after year to statically change the copyright year in my footer is something I will no longer do. So why not have it set dynamically? 10 | 11 | ```html 12 | <script> 13 | document.write(new Date().getFullYear()) 14 | </script> 15 | ``` 16 | 17 | Instead of just adding the year I also included a copyright character and my name wrapping the new script. 18 | 19 | ```html 20 | © 21 | <script> 22 | document.write(new Date().getFullYear()) 23 | </script> 24 | Chris Otto 25 | ``` 26 | -------------------------------------------------------------------------------- /content/posts/2019/2019-04-30--change-specflow-build/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Create Specflow `feature.cs` At Build Time 3 | cover: sf-logo.png 4 | author: Chris Otto 5 | tags: ['specflow', 'vs2017', 'c#'] 6 | published: true 7 | --- 8 | 9 | ## Change Feature File to Generate the `~.feature.cs` at Compile Time 10 | 11 | Whenever a new Specflow file is created in the solution it is automatically created with the SpecFlowSingleFileGenerator specification in the Custom Tool attribute. This file generator creates the background file on save instead of during the build 12 | process, follow these steps to make the change: 13 | 14 | ### Prerequisite 15 | 16 | To make the change you will need to add an extension to your instance of Visual Studio if you don't have it already. 17 | 18 | * In Visual Studio go to Extension Manage Extensions 19 | * Select Online and search for 'File Nesting' 20 | * Install the 'File Nesting' extension and close Visual Studio, once closed the installation will begin 21 | * When prompted after VS closes, select 'Modify' from the pop-up window 22 | 23 | ### Making the Change 24 | 25 | With the 'File Nesting' extension installed we are now ready to change how the ~.feature.cs is created for our feature file. 26 | 27 | * Modify the existing properties 28 | * In the Solution Explorer, R-Click on the feature file Select Properties 29 | * The Properties window should pop up below the Solution Explorer 30 | * In the Custom Tool attribute, delete 'SpecFlowSingleFileGenerator' leaving the Custom Tool attribute blank 31 | * Creating the File dependency 32 | * With the Custom Tool removed, build the solution 33 | * Once built make sure that you have 'Show All Files' selected and you should see the '~.feature.cs' file for your feature file in the Solution Explorer 34 | * If you don't see it and you already had 'Show All Files' enabled, try refreshing the Solution Explorer and it should populate 35 | * R-Click on the `~.feature.cs` file Select 'File Nesting' 36 | * Nest the file under the respective feature file 37 | -------------------------------------------------------------------------------- /content/posts/2019/2019-04-30--change-specflow-build/sf-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2019/2019-04-30--change-specflow-build/sf-logo.png -------------------------------------------------------------------------------- /content/posts/2020/2020-02-08--gatsby-change-from-md-to-mdx/gatsby-mdx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-02-08--gatsby-change-from-md-to-mdx/gatsby-mdx.png -------------------------------------------------------------------------------- /content/posts/2020/2020-02-11--gatsby-create-published-filter-for-posts/gatsby-blue-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-02-11--gatsby-create-published-filter-for-posts/gatsby-blue-green.png -------------------------------------------------------------------------------- /content/posts/2020/2020-02-11--gatsby-create-published-filter-for-posts/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Gatsby - Add a Published Filter To Posts 3 | cover: gatsby-blue-green.png 4 | author: Chris Otto 5 | tags: ['gatsby','react','graphql'] 6 | published: true 7 | --- 8 | 9 | I like [Dev.to](www.dev.to)'s published filter. It allows you to continue to write posts until they're ready to be 10 | viewed by everyone. If you are not familiar with the feature; in the `frontmatter` of your post there is a `boolean` named 11 | `published`. If it's set to false, the post is visible to you and people that have the link. Once it's set to true then 12 | it is visible to the world! 13 | 14 | I wanted to add that feature to my personal [Gatsby](www.gatsbyjs.com) site so that I can have articles in progress while 15 | not finishing them if I need to commit other changes. The one difference with my implementation is that the post won't be 16 | available to the client at all until it is published. The outline for the change: 17 | 18 | - Update all existing posts have `published: true` in their `frontmatter` 19 | - Add/Update filters to Graphql queries throughout my site keying in on the field set to `true` 20 | - gatsby-config.js 21 | - gatsby-node.js 22 | - Pages 23 | - Templates 24 | 25 | ## Applying the Updates 😎 26 | 27 | ### Add `published` to the frontmatter 28 | 29 | ```markdown 30 | tags: ['gatsby','react'] 31 | + published: true 32 | --- 33 | ``` 34 | 35 | This change was made to all existing posts so that right away the Graphql queries I update down the line return data. Also 36 | I created a dummy post that had the `published` boolean set to false to verify that it was excluded from the list of posts. 37 | 38 | ### Adding the filter to `gatsby-config.js` queries 39 | 40 | Within my `gatsby-config.js` I have two queries. One query hooks up to the Algolia search for my site and the other populates 41 | the RSS feed. For both queries, I do not want posts that are not published to show up. 42 | 43 | #### Algolia query 44 | 45 | ```graphql 46 | allMdx( 47 | filter: { 48 | fields: { slug: { ne: null } }, 49 | fileAbsolutePath: { regex: "/posts/"}, 50 | + frontmatter: { published: { eq: true } } 51 | } 52 | ``` 53 | 54 | #### RSS Feed 55 | 56 | ```graphql 57 | frontmatter: { 58 | author: { ne: null }, 59 | + published: { eq: true} 60 | } 61 | ``` 62 | 63 | ### Apply filter to `gatsby-node.js`, components and templates 64 | 65 | The `createPages` in `gatsby-node.js` function uses the query to find out which pages should be created in my case this applies to posts and pages. 66 | Applying the filter to the components and pages makes sure that the unpublished posts don't creep in and cause errors because there 67 | is no post page to route to. 68 | 69 | #### gatsby-node 70 | 71 | ```graphql 72 | allMdx( 73 | + filter: { fields: { slug: { ne: null } }, frontmatter: { published: { eq: true } } } 74 | sort: { fields: [fields___prefix], order: DESC } 75 | limit: 1000 76 | ) 77 | ``` 78 | 79 | #### Tag Page and Index Template 80 | 81 | ```graphql 82 | posts: allMdx( 83 | filter: { 84 | fileAbsolutePath: { regex: "//posts/[0-9]+.*--/" } 85 | + frontmatter: { published: { eq: true } } 86 | } 87 | ``` 88 | 89 | #### Tag Template 90 | 91 | ```graphql 92 | query PostsByTag($tag: String) { 93 | allMdx( 94 | limit: 1000 95 | sort: { fields: [fields___prefix], order: DESC } 96 | + filter: { frontmatter: { tags: { in: [$tag] }, published: { eq: true } } } 97 | ) 98 | ``` 99 | 100 | ### Testing and Wrap Up 🙌 101 | 102 | At this point all the changes have been made to test out the change. All existing posts should be present and flow through 103 | searching, post pages, tag pages and RSS feed, all except for the one dummy post. I did some smoke testing by navigating around 104 | the components and pages updated to ensure that everything was working as expected and all existing automated tests passed. Win-win. 105 | Now I can keep posts as a work in progress until they're ready to be published and still get other development work merged in. 106 | 107 | Do you filter out in progress work from your Gatsby site? What approach did you take? -------------------------------------------------------------------------------- /content/posts/2020/2020-05-21--gatsby-create-an-audience-with-mailchimp/finished_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-05-21--gatsby-create-an-audience-with-mailchimp/finished_form.png -------------------------------------------------------------------------------- /content/posts/2020/2020-05-21--gatsby-create-an-audience-with-mailchimp/mail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-05-21--gatsby-create-an-audience-with-mailchimp/mail.jpg -------------------------------------------------------------------------------- /content/posts/2020/2020-06-01--compress-your-images-with-tinify/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-06-01--compress-your-images-with-tinify/image.jpg -------------------------------------------------------------------------------- /content/posts/2020/2020-06-30--add-linting-to-create-react-app/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-06-30--add-linting-to-create-react-app/image.jpg -------------------------------------------------------------------------------- /content/posts/2020/2020-06-30--add-linting-to-create-react-app/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Add Linting to Create-React-App 3 | cover: image.jpg 4 | author: Chris Otto 5 | tags: ['react', 'javascript', 'node'] 6 | published: true 7 | --- 8 | 9 | Image from [Free Illustrations](https://freellustrations.com/). 10 | 11 | Create-React-App gives a nice bootstrapped project. I like being able to lint outside of the build or run process of the application, like on pre-commit hooks with husky. To do that I needed to add linting to my create-react-app. Follow these steps to add linting to your create-react-app project and get linting outside of your build process. 12 | 13 | ## Install packages 📦 14 | 15 | This is the most painful part of the process I kept installing one package after another to see if linting worked. All-in-all eight packages later I finally had everything there and ready to run based on the configurations from create-react-app. 16 | 17 | ```bash 18 | npm i --save-dev babel-eslint eslint-config-react-app eslint-plugin-flowtype eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks 19 | ``` 20 | 21 | ```bash 22 | yarn add --dev babel-eslint eslint-config-react-app eslint-plugin-flowtype eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks 23 | ``` 24 | 25 | ## Add ESLint Configuration to `package.json` 26 | 27 | You can either supply this in a separate file (`.eslintrc.json`/`.eslintrc.js`) or right in your `package.json`. For larger configuration changes I would recommend a separate file but I'm just extending the react-app configuration and applying a couple of rule adjustments. 28 | 29 | ```json 30 | "eslintConfig": { 31 | "extends": "react-app", 32 | "rules": { 33 | "semi": 0, 34 | "no-console": 0 35 | } 36 | }, 37 | ``` 38 | 39 | ## Add Linting Script 40 | 41 | Now, all we need to do is to add the linting script to our `package.json` and we'll be able to lint whenever we want. Even hook up husky or add a specific step for linting in our CI/CD pipeline. 42 | 43 | ```json 44 | "scripts": { 45 | ... 46 | "lint": "node_modules/.bin/eslint --ext js src" 47 | }, 48 | ``` 49 | -------------------------------------------------------------------------------- /content/posts/2020/2020-07-10--debug-gatsby-simulator/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Gatsby Develop with iOS Simulator 3 | cover: laptop.jpg 4 | author: Chris Otto 5 | tags: ['gatsby', 'react', 'ios'] 6 | published: true 7 | --- 8 | 9 | Image from [@norwood](https://unsplash.com/@nordwood) at Unsplash. 10 | 11 | Chrome DevTools have come a long way in enabling users to mimic mobile devices. However, if you wanted to debug your Gatsby application locally for mobile specific features you can do that through the iOS Simulator. In order to do this you need to have a Mac, there are other ways around it without a Mac but this post will focus on setting up your Gatsby site to be accessed through the simulator. 12 | 13 | ## Prerequisites 14 | 15 | - A Mac computer 16 | - XCode installed 17 | - Developer Tools Enabled in your Mac's Safari browser 18 | - Launch Safari 19 | - Click Safari in the Menu 20 | - Preferences 21 | - Advanced 22 | - Enable `Show Develop menu in menu bar` 23 | 24 | ### Start Gatsby 👨‍🚀 25 | 26 | First thing we'll need to do is start the Gatsby site in a special way allowing for local network access. This can be done by using the `-H` parameter when starting `gatsby develop` 27 | 28 | ```bash 29 | gatsby develop -H 0.0.0.0 30 | ``` 31 | 32 | ### Find IP Address 💻 33 | 34 | Next you need to find your local IP Address within your network and that can be done in the terminal with the following command. 35 | 36 | ```bash 37 | ipconfig getifaddr en0 38 | ``` 39 | 40 | ### Launch the Simulator 🚀 41 | 42 | For this next part we'll be starting XCode and starting to iOS Simulator. 43 | 44 | - Launch XCode 45 | - Select XCode in the Menu 46 | - Open Developer Tool 47 | - Simulator 48 | 49 | With the simulator up and running we'll need to navigate to our locally served instance of our Gatsby site. 50 | 51 | - Open Safari in the simulator 52 | - In the address bar paste in %IP_ADDRESS%:8000 53 | 54 | With the site active in the simulator we can now open up the Safari Developer Tools for debugging our site in the simulator. 55 | 56 | - Start Safari on your computer 57 | - Go the Develop in the menu 58 | - Expand the Simulator option 59 | - Click on the IP Address listed for your site 60 | 61 | Now you'll be able to use your site like a true mobile user would can even change geolocation settings, accelerometer settings and all the other benefits of iOS Simulator for using your Gatsby application. 62 | -------------------------------------------------------------------------------- /content/posts/2020/2020-07-10--debug-gatsby-simulator/laptop.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2020/2020-07-10--debug-gatsby-simulator/laptop.jpg -------------------------------------------------------------------------------- /content/posts/2021/2021-01-08--gatsby-error-monitoring-with-sentry/error_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-01-08--gatsby-error-monitoring-with-sentry/error_image.jpg -------------------------------------------------------------------------------- /content/posts/2021/2021-01-08--gatsby-error-monitoring-with-sentry/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Gatsby Error Monitoring with Sentry 3 | cover: error_image.jpg 4 | author: Chris Otto 5 | tags: ['react', 'gatsby', 'sentry'] 6 | published: true 7 | --- 8 | 9 | Image from [Free Illustrations](https://freellustrations.com/). 10 | 11 | Being able to monitor when your application encounters a bug in production is a nice thing to have for your personal or professional projects. The folks at [Sentry](https://sentry.io/welcome/) have created a solution to monitor errors within your applications and alert you when they happen. I have set this up for my personal site and a website I made for my wife. This guide will walk you through what you need to do to add sentry monitoring to your Gatsby applications. 12 | 13 | ## Create Sentry Account and Project 14 | 15 | First, we need to create an account with [Sentry](https://sentry.io/welcome/). Head over to their site, select `Sign Up` and either create an account or use Github or any of the other integrations that they have. 16 | 17 | Once signed in: 18 | 19 | - Create a new project 20 | - Choose `React` for the platform 21 | - Set your notification preferences 22 | - Name your project 23 | - I use the domain or the Github project name of the website I'm adding sentry to 24 | - Choose or create the team that the project should live in 25 | - Hit `Create Project` 26 | 27 | The next page will give you some default React code for adding Sentry to your application. You can disregard this for now, just copy your Sentry DSN from the code, we'll need this later for setting up the Gatsby plugin. 28 | 29 | ## Install Packages 📦 30 | 31 | In your project, add the `gatsby-plugin-sentry`: 32 | 33 | ```sh 34 | npm install gatsby-plugin-sentry 35 | ``` 36 | 37 | or 38 | 39 | ```sh 40 | yarn add gatsby-plugin-sentry 41 | ``` 42 | 43 | ## Gatsby Configuration Changes 👨‍💻 44 | 45 | Using the Sentry DSN we copied from creating our project, we now need to configure our Gatsby plugin to hook up to our Sentry project. 46 | 47 | In your `gatsby-config.js` add: 48 | 49 | ```js 50 | { 51 | resolve: 'gatsby-plugin-sentry', 52 | options: { 53 | dsn: process.env.SENTRY_DSN, 54 | }, 55 | }, 56 | ``` 57 | 58 | You'll want to set up your Sentry DSN up as an environment variable because you don't want your secret for your project being exposed. This means adding it to any CI/CD (Github Actions, TravisCI, etc.) or deployment (Netlify, Vercel, etc.) configurations as well. 59 | 60 | ## Create the Error Boundary Component 🧩 61 | 62 | We're going to create an Error Boundary component to catch any errors in our application and use it to send the details to Sentry. Later we're going to wrap a Gatsby layout component with our new error boundary so any page or post in your site will have the error boundary available. 63 | 64 | Create a new file called `ErrorBoundary.js` where you define your components in your project (for me this is in `src/common/components`): 65 | 66 | ```js 67 | import React from 'react' 68 | import PropTypes from 'prop-types' 69 | import Sentry from 'gatsby-plugin-sentry' 70 | 71 | class ErrorBoundary extends React.Component { 72 | constructor(props) { 73 | super(props) 74 | this.state = { error: null } 75 | } 76 | 77 | componentDidCatch(error, errorInfo) { 78 | this.setState({ error }) 79 | Sentry.configureScope((scope) => { 80 | Object.keys(errorInfo).forEach((key) => { 81 | scope.setExtra(key, errorInfo[key]) 82 | }) 83 | }) 84 | Sentry.captureException(error) 85 | } 86 | 87 | render() { 88 | if (this.state.error) { 89 | // render fallback UI 90 | return <h1>Something went wrong!</h1> 91 | } else { 92 | // when there's not an error, render children untouched 93 | return this.props.children 94 | } 95 | } 96 | } 97 | 98 | ErrorBoundary.propTypes = { 99 | children: PropTypes.object.isRequired, 100 | } 101 | 102 | export default ErrorBoundary 103 | ``` 104 | 105 | ## Wrap Contents of Layout Component with Error Boundary 106 | 107 | Now in your layout component import the new ErrorBoundary component: 108 | 109 | ```js 110 | import ErrorBoundary from '../components/ErrorBoundary' 111 | ``` 112 | 113 | Wrap whatever was in your Layout component with the ErrorBoundary component: 114 | 115 | ```js 116 | const Layout = (props) => ( 117 | + <ErrorBoundary> 118 | <React.Fragment> 119 | <Helmet> 120 | <body className="bg-white mid-gray" /> 121 | </Helmet> 122 | <Navbar /> 123 | {props.children} 124 | <Footer /> 125 | </React.Fragment> 126 | + </ErrorBoundary> 127 | ) 128 | ``` 129 | 130 | ## Conclusion 131 | 132 | Just like that you should be all setup. Make sure to keep an eye out for Sentry e-mails coming from your application and this will give you a great way to issues reported from your applications in production. Cheers 🍻! 133 | -------------------------------------------------------------------------------- /content/posts/2021/2021-01-15--react-netlify-client-side-routing/computer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-01-15--react-netlify-client-side-routing/computer.jpg -------------------------------------------------------------------------------- /content/posts/2021/2021-01-15--react-netlify-client-side-routing/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Netlify - Client Side Routing 3 | cover: computer.jpg 4 | author: Chris Otto 5 | tags: ['netlify', 'react', 'react-router'] 6 | published: true 7 | --- 8 | 9 | I have a nice little [side project](https://library.chrisotto.dev/) based on the Goodreads API (yes, my key is still active). I had it up for a while and whenever I would refresh a page while it was deployed it would lose context to the page. This is because Netlify didn't know how to handle the state I had gotten myself into because the routing in `create-react-app` that I was using was `react-router`. 10 | 11 | Luckily enough this is an easy fix, so if you also have the following: 12 | 13 | - Create React App client side routing through `react-router` 14 | - Deploy your site on Netlify 15 | 16 | ...then you will be able to make the same change and have your page context persist after refreshes! 17 | 18 | ## Creating a Redirects File 💻 19 | 20 | In your `public` directory create a new `__redirects` file with the following code: 21 | 22 | ```txt 23 | /* /index.html 200 24 | ``` 25 | 26 | Now when the project gets built by Netlify, Create-React-App will place the contents of the `public` directory into the build output. Allowing Netlify to handle `pushState` from within your application. That's it! 27 | -------------------------------------------------------------------------------- /content/posts/2021/2021-01-21--ssh-synology-nas/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Synology NAS - SSH To Find User Info 3 | cover: synology.png 4 | author: Chris Otto 5 | tags: ['synology', 'nas'] 6 | published: true 7 | --- 8 | 9 | Image courteous of [unsplash](https://unsplash.com/). 10 | 11 | The user information for your user is useful for setting up Docker images/containers that need access. You can either setup a dedicated Docker user in your NAS or any other user that has `admin` privileges. 12 | 13 | ## Enable SSH 14 | 15 | In order to be able to SSH into the NAS, first we need to enable SSH: 16 | 17 | - In DSM open up the Control Panel 18 | - Terminal & SNMP (make sure you're in advanced mode in the control panel) 19 | - In the Terminal Tab 20 | - Click the checkbox to Enable SSH Service 21 | - Change the port from the default port of `22` 22 | - Apply 23 | 24 | ## Open Up The Terminal And Find User Info 25 | 26 | Now that SSH is enabled we head to the terminal in order to login into our NAS via SSH. If you're on Windows using the default Command Prompt will work or Terminal on MacOS/Linux. 27 | 28 | With the terminal open enter the following command: 29 | 30 | ```bash 31 | ssh %USERNAME%@%IP_ADDRESS% -p %PORT% 32 | ``` 33 | 34 | - %USERNAME% - The username for the account that has `admin` access in your NAS. It's standard practice to disable the default admin and guest account for your NAS. 35 | - %IP_ADDRESS% - The IP address of your Synology NAS 36 | - %PORT% - Port you set up for SSH when enabling SSH 37 | 38 | You'll then be prompted to enter your password, afterwords you should be greeted with the following your terminal: 39 | 40 | ```bash 41 | %USERNAME%@%SYNOLOGY_NAS_NAME%:~$ 42 | ``` 43 | 44 | Now enter in `id` and see the information for your user: 45 | 46 | ```bash 47 | %USERNAME%@%SYNOLOGY_NAS_NAME%:~$id 48 | uid=1033(%USERNAME%) gid=100(users) groups=100(users),101(administrators)... 49 | ``` 50 | 51 | The key information that we need to take out of this is the `uid` and the administrators group ID. A lot of the containers on [Linux Server](https://fleet.linuxserver.io/) require these IDs but they will be mapped a little bit differently: 52 | 53 | - `uid` = `PUID` 54 | - Admin Group ID = `PGID` 55 | 56 | That's it. With those two IDs saved off you should now have the prerequisites to set up some Docker containers on your NAS. I will have follow-up posts for some of the containers I've set up on my NAS. Cheers! 🍻 57 | -------------------------------------------------------------------------------- /content/posts/2021/2021-01-21--ssh-synology-nas/synology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-01-21--ssh-synology-nas/synology.png -------------------------------------------------------------------------------- /content/posts/2021/2021-01-25--portainer-docker-nas/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Portainer - Docker Container Manager For Your NAS 3 | cover: shipyard.jpg 4 | author: Chris Otto 5 | tags: ['synology', 'docker', 'nas'] 6 | published: true 7 | --- 8 | 9 | Image courteous of [Pixabay](https://pixabay.com/). 10 | 11 | Portainer is an application that makes it easy for you to manage docker containers that you have running on your system. It is a great tool to add to any NAS running containers or homeserver doing the same. It can also be used for Kubernetes management if that is something you have running or are interested. Some of the features I like: 12 | 13 | - SSH into the container right from the UI 14 | - View logs 15 | - See resources consumed by each container 16 | 17 | ## Prerequisites 📃 18 | 19 | - Download Docker from Package Center 20 | - In DSM head over to Package Center 21 | - Search for Docker 22 | - Install 23 | - Enable SSH for your NAS 24 | - If you don't have SSH enabled follow this [guide](https://chrisotto.dev/ssh-synology-nas/). 25 | 26 | ## Create Folder Structure 📂 27 | 28 | In DSM, open up the File Station: 29 | 30 | - Installing docker creates a `docker` folder at the root of your volume 31 | - Create a new folder in the `docker` folder for Portainer 32 | - I always create the folder based on the name of the image/container I'm setting up, in this case `portainer` 33 | 34 | ## SSH Into Your NAS And Run the Image 📦 35 | 36 | Using your preferred terminal SSH into your NAS, once logged in run the following: 37 | 38 | ```bash 39 | %USERNAME%@%NAS_NAME%:~$sudo su - 40 | ``` 41 | 42 | You may be prompted to re-enter your password and you should do so. This command puts us in the root environment with the root user. Your terminal windows should now look like: 43 | 44 | ```bash 45 | root@%NAS_NAME%:~# 46 | ``` 47 | 48 | Copy and paste the following snippet in your terminal 49 | 50 | ```bash 51 | root@%NAS_NAME%:~# docker run -d -p 8000:8000 -p 9000:9000 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v /volume1/docker/portainer:/data portainer/portainer-ce@latest 52 | ``` 53 | 54 | If you have any other containers already setup that may be using ports `8000` or `9000` make sure to change that before executing the Docker run command. Now we just have a to setup the portainer instance in the web interface and we'll be all set! 55 | 56 | ## Setup Portainer 🛳 57 | 58 | In your browser navigate to `http://%SYNOLOGY_IP_ADDRESS%:9000`. Here we will be prompted to create a new user for Portainer. 59 | 60 | - Create your username 61 | - Create a new password 62 | - Confirm your password 63 | - Uncheck `Allow collection...` 64 | - Click `Create User` 65 | 66 | Once your user is created you will be brought to a screen to choose which container environment we want to manage with Portainer. 67 | 68 | - Select Docker (or if you are using this to manage Kubernetes then choose that one) 69 | - Click `Connect` 70 | 71 | Now you should be looking at the Portainer dashboard, congratulations you have successfully set up Portainer in your NAS/homeserver through Docker! Feel free to get out their [image](https://hub.docker.com/r/portainer/portainer-ce) in Docker Hub or the Community Edition [documentation](https://www.portainer.io/products/community-edition) for more information! Cheers! 🍻 72 | -------------------------------------------------------------------------------- /content/posts/2021/2021-01-25--portainer-docker-nas/shipyard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-01-25--portainer-docker-nas/shipyard.jpg -------------------------------------------------------------------------------- /content/posts/2021/2021-01-31--pihole-docker-nas/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: PiHole - Docker NAS Setup 3 | cover: pi-hole.png 4 | author: Chris Otto 5 | tags: ['synology', 'docker', 'nas'] 6 | published: true 7 | --- 8 | 9 | Pi-hole is an awesome tool that let's you block certain requests on your home network from devices. The way that Pi-hole does this by either hosting it on a Raspberry Pi or hosted on a machine on your network through Docker. Once that is done you set the IP address of the Pi-hole as your DNS server or if you can't modify your DNS server due ISP restrictions you can set it as your DHCP server. This guide we'll walk through setting up Pi-hole through Docker on a Synology NAS. 10 | 11 | ## Prerequisites 📃 12 | 13 | - Download Docker from Package Center 14 | - In DSM head over to Package Center 15 | - Search for Docker 16 | - Install 17 | 18 | ## Create Folder Structure 📂 19 | 20 | In DSM, open up the File Station: 21 | 22 | - Installing docker creates a `docker` folder at the root of your volume 23 | - Create a new folder in the `docker` folder for Portainer 24 | - I always create the folder based on the name of the image/container I'm setting up, in this case `pihole` 25 | - In the `pihole` directory create two new folders `pihole` and `dnsmasq.d` 26 | - These folders will get mounted as volumes for the docker container later on 27 | 28 | ## Downloading and Configuring the Image 🚢 29 | 30 | Open on `Docker` on your NAS. In the `Registry` tab search for pihole. The image that we're looking for is the official Pi-hole image, `pihole/pihole`. Select the image and select Download or just double click it. Once the image has finished downloading, head on over to the `Images` tab and click Launch or again double click it. 31 | 32 | Now you should be in the `Create Container` pop-over: 33 | 34 | - Enter the container name, `pihole` 35 | - Enable `Execute container using high privileges` 36 | - Click Advanced Settings 37 | 38 | In the `Advanced Settings` main tab: 39 | 40 | - Enable `Enable auto-restart` 41 | 42 | In the `Volume` tab, we're going to add the folders we created to the volumes for container, make you match the mount path below: 43 | 44 | | File/Folder | Mount Path | 45 | | :---------------------- | :------------- | 46 | | docker/pihole/dnsmasq.d | /etc/dnsmasq.d | 47 | | docker/pihole/pihole | /etc/pihole | 48 | 49 | <br /> 50 | In the `Network` tab: 51 | 52 | - Enable `Use the same network as Docker Host` 53 | 54 | Finally, in the `Environment` tab we need to set/change some of the default environment variables that come with the image. You'll need to come up with a password for the web interface and that will get set for one of the variables. Also, decide on what port you want this to run on, the default is `8080` so if you have another application using that port pick a new one. Lastly, we'll need to set one of variables to the IP address of your Synology NAS or IP address of where ever you are running the container: 55 | 56 | | Variable | Value | 57 | | :---------------- | :--------------------- | 58 | | WEBPASSWORD | [Create a password] | 59 | | DNSMASQ_LISTENING | local | 60 | | WEB_PORT | 8080 | 61 | | ServerIP | [NAS IP or Machine IP] | 62 | 63 | ## Launch the Container 🚀 64 | 65 | With everything configured you're now ready to click `Apply` and launch your new Pi-hole container. Once the container has started, head on over to `http://[YOUR IP ADDRESS]:[YOUR PORT]/admin`. You'll need to login with the password that we set as the environment variables earlier. 66 | 67 | ## Router Changes 📶 68 | 69 | Pi-hole is running and ready to block traffic on your network, but you won't see anything on the admin dashboard until you make the necessary changes to your router. This process is very specific to which router you have so I won't detail it here as it will most likely be different for everyone. But login into your router and change the IP address of your DNS server to the IP address of your NAS. If you are not able to modify your DNS, then you can set Pi-hole to be your DHCP server. 70 | 71 | One thing I have noticed with having the Pi-hole set as my DNS server on my router is that I only log traffic for one device. The router. Which makes sense because all traffic is coming to Pi-hole from the router. One way you could get more detailed information per device is using DHCP or individually changing the DNS per device on your network to the Pi-hole. I don't really mind not seeing device specific statistics, so I left it as-is. 72 | 73 | I hope you enjoyed this little guide and happy blocking, cheers! 🍻 74 | -------------------------------------------------------------------------------- /content/posts/2021/2021-01-31--pihole-docker-nas/pi-hole.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-01-31--pihole-docker-nas/pi-hole.png -------------------------------------------------------------------------------- /content/posts/2021/2021-02-05--calibre-library-docker-nas/library.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-02-05--calibre-library-docker-nas/library.jpg -------------------------------------------------------------------------------- /content/posts/2021/2021-02-19--watchtower-docker-nas/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Watchtower - Docker NAS Setup 3 | cover: watchtower.jpg 4 | author: Chris Otto 5 | tags: ['docker','nas','watchtower'] 6 | published: true 7 | --- 8 | 9 | Image courteous of [@kristsll on Unsplash](https://unsplash.com/@kristsll). 10 | 11 | Watchtower is a container that will update all your other running containers when a new version is published to the docker registry that they are setup through. This is helpful for your Docker containers to stay up to date on your home server or NAS. If you would like to install on on your home server and not through a NAS you can jump down to the running the container portion. 12 | 13 | ## Prerequisites 📃 14 | 15 | - Enable SSH for your NAS 16 | - If you don't have SSH enabled follow this [guide](https://chrisotto.dev/ssh-synology-nas/) 17 | 18 | ## SSH Into Your NAS 💻 19 | 20 | Now that SSH is enabled on our NAS we need to SSH into the machine so that we can run the `docker run` command. Open up the terminal on your machine and enter the following: 21 | 22 | ```bash 23 | ssh %USERNAME%@%IP_ADDRESS% -p %PORT% 24 | ``` 25 | 26 | After you enter in your password you should be greeted with: 27 | 28 | ```bash 29 | %USERNAME%@%SYNOLOGY_NAS_NAME%:~$ 30 | ``` 31 | 32 | ## Run the Container 🐳 33 | 34 | In your terminal enter the following `docker run` command: 35 | 36 | ```bash 37 | sudo docker run --name="watchtower" -d --restart=always -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower:latest 38 | ``` 39 | 40 | This will run Watchtower on your NAS allowing it to update existing running images whenever there is a new version pushed! That's it for the main portion of getting Watchtower up and running. Here a few extra notes since I've been running it for a few months: 41 | 42 | - I highly recommend getting Portainer set up to maintain the old images that get replaced, I have a guide on how to set that up [here](https://chrisotto.dev/portainer-docker-nas/). 43 | - If you want to exclude certain images from being updated you can stop them and rerun them with a label of ` 44 | com.centurylinklabs.watchtower.enable=false` 45 | 46 | Happy containerization! Cheers! 🍻 47 | 48 | -------------------------------------------------------------------------------- /content/posts/2021/2021-02-19--watchtower-docker-nas/watchtower.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisotto6/chrisottodev/38713cee40470b40979f6c61f3734f47864efb36/content/posts/2021/2021-02-19--watchtower-docker-nas/watchtower.jpg -------------------------------------------------------------------------------- /coverage/badge-branches.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="20"><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="160" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="117" height="20" fill="#555"/><rect x="117" width="43" height="20" fill="#4c1"/><rect width="160" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="595" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="1070">Coverage:branches</text><text x="595" y="140" transform="scale(.1)" textLength="1070">Coverage:branches</text><text x="1375" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="1375" y="140" transform="scale(.1)" textLength="330">100%</text></g></svg> -------------------------------------------------------------------------------- /coverage/badge-functions.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="20"><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="160" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="117" height="20" fill="#555"/><rect x="117" width="43" height="20" fill="#4c1"/><rect width="160" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="595" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="1070">Coverage:functions</text><text x="595" y="140" transform="scale(.1)" textLength="1070">Coverage:functions</text><text x="1375" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="1375" y="140" transform="scale(.1)" textLength="330">100%</text></g></svg> -------------------------------------------------------------------------------- /coverage/badge-lines.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="136" height="20"><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="136" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="93" height="20" fill="#555"/><rect x="93" width="43" height="20" fill="#4c1"/><rect width="136" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="475" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="830">Coverage:lines</text><text x="475" y="140" transform="scale(.1)" textLength="830">Coverage:lines</text><text x="1135" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="1135" y="140" transform="scale(.1)" textLength="330">100%</text></g></svg> -------------------------------------------------------------------------------- /coverage/badge-statements.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="172" height="20"><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="172" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="129" height="20" fill="#555"/><rect x="129" width="43" height="20" fill="#4c1"/><rect width="172" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110"><text x="655" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="1190">Coverage:statements</text><text x="655" y="140" transform="scale(.1)" textLength="1190">Coverage:statements</text><text x="1495" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="1495" y="140" transform="scale(.1)" textLength="330">100%</text></g></svg> -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:9000/", 3 | "ignoreTestFiles": [""], 4 | "integrationFolder": "cypress/e2e", 5 | "video": false, 6 | "chromeWebSecurity": false 7 | } 8 | -------------------------------------------------------------------------------- /cypress/e2e/a11y.test.js: -------------------------------------------------------------------------------- 1 | const A11Y_CONFIG = { 2 | checks: [ 3 | { 4 | id: 'aria-valid-attr-value', 5 | enabled: false, 6 | }, 7 | ], 8 | } 9 | 10 | describe('Accessibility checks', () => { 11 | it('Main Page', () => { 12 | cy.visit('/') 13 | cy.wait(2000) 14 | cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 15 | }) 16 | 17 | //it('About Page', () => { 18 | // cy.visit('/about') 19 | // cy.wait(1000) 20 | // cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 21 | //}) 22 | 23 | it('Tags Page', () => { 24 | cy.visit('/tag') 25 | cy.wait(1000) 26 | cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 27 | }) 28 | 29 | //it('Projects Page', () => { 30 | // cy.visit('/project') 31 | // cy.wait(1000) 32 | // cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 33 | //}) 34 | 35 | //it('Search Page', () => { 36 | // cy.visit('/search') 37 | // cy.injectAxe() 38 | // cy.checkA11y() 39 | //}) 40 | 41 | it('Contact Page', () => { 42 | cy.visit('/contact') 43 | cy.wait(1000) 44 | cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 45 | }) 46 | 47 | //it('404 Page', () => { 48 | // cy.visit('/404') 49 | // cy.injectAxe().configureAxe(A11Y_CONFIG).checkA11y() 50 | //}) 51 | }) 52 | -------------------------------------------------------------------------------- /cypress/e2e/index.test.js: -------------------------------------------------------------------------------- 1 | describe('Index Page', () => { 2 | it('Index Page Navigation', () => { 3 | cy.visit('/') 4 | cy.title().should('eq', 'Chris Otto | Father, Developer, Test Engineer') 5 | cy.get('button[aria-label="scroll"]').click() 6 | }) 7 | it('Posts are rendered', () => { 8 | cy.get('#post-list').should('be.visible') 9 | cy.get('#post-list>li').should('be.visible') 10 | cy.get('#post-list>li>.link>.gatsby-image-outer-wrapper') 11 | .first() 12 | .should('be.visible') 13 | cy.get('#post-list>li>.link>div') 14 | .first() 15 | .should('be.visible') 16 | cy.get('#post-list>li>.link>p') 17 | .first() 18 | .should('be.visible') 19 | .next() 20 | .should('be.visible') 21 | }) 22 | it('Footer Post Navigation', () => { 23 | cy.get('footer').scrollIntoView() 24 | cy.get('#post-page-list').should('be.visible') 25 | cy.get('#post-page-list>li').should('be.visible') 26 | }) 27 | it('Social Links', () => { 28 | cy.get('.social').should('be.visible') 29 | cy.get('.social') 30 | .find('a') 31 | .should('have.length', 5) 32 | cy.get('.social>a') 33 | .first() 34 | .should('have.attr', 'title', 'dev') 35 | .next() 36 | .should('have.attr', 'title', 'github') 37 | .next() 38 | .should('have.attr', 'title', 'instagram') 39 | .next() 40 | .should('have.attr', 'title', 'linkedin') 41 | .next() 42 | .should('have.attr', 'title', 'twitter') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /cypress/e2e/pages.test.js: -------------------------------------------------------------------------------- 1 | describe('Markdown Generated Pages Tests', () => { 2 | it('Resources Page', () => { 3 | cy.visit('/resources') 4 | cy.get('[id=Resources]').contains('Resources') 5 | }) 6 | 7 | it('Success Page', () => { 8 | cy.visit('/success') 9 | cy.get('[id=Success]').contains('Success') 10 | }) 11 | }) 12 | 13 | describe('Pages Tests', () => { 14 | it('About Page', () => { 15 | cy.visit('/about') 16 | cy.get('[id=About]').contains('About') 17 | }) 18 | 19 | it('Tags Page', () => { 20 | cy.visit('/tag') 21 | cy.get('[id="Posts by tags"]').contains('Posts by tags') 22 | }) 23 | 24 | it('Projects Page', () => { 25 | cy.visit('/project') 26 | cy.get('[id=Projects]').contains('Projects') 27 | }) 28 | 29 | it('Uses Page', () => { 30 | cy.visit('/uses') 31 | cy.get('[id=Uses]').contains('Uses') 32 | }) 33 | 34 | it('Search Page', () => { 35 | cy.visit('/search') 36 | cy.get('.ais-SearchBox-input').should('have.attr', 'placeholder', 'Search') 37 | }) 38 | 39 | it('Contact Page', () => { 40 | cy.visit('/contact') 41 | cy.get('[id=Contact]').contains('Contact') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // cypress/plugins/index.js 2 | 3 | // export a function 4 | module.exports = (on, config) => { 5 | // configure plugins here 6 | } 7 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | import 'cypress-axe' 2 | import '@testing-library/cypress/add-commands' 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | ports: 8 | - "8000:8000" 9 | - "9929:9929" 10 | - "9230:9230" 11 | volumes: 12 | - /app/node_modules 13 | - .:/app 14 | environment: 15 | - NODE_ENV=development 16 | - GATSBY_WEBPACK_PUBLICPATH=/ 17 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | // Prism themes 2 | 3 | require('prismjs/plugins/command-line/prism-command-line.css') 4 | //require("prismjs/themes/prism.css") 5 | require('prismjs/themes/prism-okaidia.css') 6 | //require("prismjs/themes/prism-tomorrow.css") 7 | //require("prismjs/themes/prism-solarizedlight.css") 8 | //require("prismjs/themes/prism-coy.css") 9 | //require("prismjs/themes/prism-dark.css") 10 | //require("prismjs/themes/prism-twilight.css") 11 | //require("prismjs/themes/prism-funky.css") 12 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | //const webpack = require("webpack"); 2 | const _ = require('lodash') 3 | const path = require('path') 4 | const Promise = require('bluebird') 5 | 6 | const { createFilePath } = require(`gatsby-source-filesystem`) 7 | 8 | exports.onCreateNode = ({ node, getNode, actions }) => { 9 | const { createNodeField } = actions 10 | if (node.internal.type === `Mdx`) { 11 | const slug = createFilePath({ node, getNode }) 12 | const fileNode = getNode(node.parent) 13 | const source = fileNode.sourceInstanceName 14 | const separtorIndex = ~slug.indexOf('--') ? slug.indexOf('--') : 0 15 | const shortSlugStart = separtorIndex ? separtorIndex + 2 : 0 16 | 17 | if (source !== 'parts') { 18 | createNodeField({ 19 | node, 20 | name: `slug`, 21 | value: `${separtorIndex ? '/' : ''}${slug.substring(shortSlugStart)}`, 22 | }) 23 | } 24 | createNodeField({ 25 | node, 26 | name: `prefix`, 27 | value: separtorIndex ? slug.substring(6, separtorIndex) : '', 28 | }) 29 | createNodeField({ 30 | node, 31 | name: `source`, 32 | value: source, 33 | }) 34 | } 35 | } 36 | 37 | exports.createPages = ({ graphql, actions }) => { 38 | const { createPage } = actions 39 | 40 | return new Promise((resolve, reject) => { 41 | const postTemplate = path.resolve('./src/templates/PostTemplate.js') 42 | const pageTemplate = path.resolve('./src/templates/PageTemplate.js') 43 | const tagTemplate = path.resolve('./src/templates/TagTemplate.js') 44 | 45 | resolve( 46 | graphql( 47 | ` 48 | { 49 | allMdx( 50 | filter: { fields: { slug: { ne: null } }, frontmatter: { published: { eq: true } } } 51 | sort: { fields: [fields___prefix], order: DESC } 52 | limit: 1000 53 | ) { 54 | edges { 55 | node { 56 | id 57 | fields { 58 | slug 59 | prefix 60 | source 61 | } 62 | frontmatter { 63 | title 64 | tags 65 | } 66 | } 67 | } 68 | } 69 | } 70 | ` 71 | ).then((result) => { 72 | if (result.errors) { 73 | console.log(result.errors) 74 | reject(result.errors) 75 | } 76 | 77 | const items = result.data.allMdx.edges 78 | 79 | // Create tag list 80 | const tagSet = new Set() 81 | items.forEach((edge) => { 82 | const { 83 | node: { 84 | frontmatter: { tags }, 85 | }, 86 | } = edge 87 | 88 | if (tags && tags != null) { 89 | tags.forEach((tag) => { 90 | if (tag && tag !== null) { 91 | tagSet.add(tag) 92 | } 93 | }) 94 | } 95 | }) 96 | 97 | // Create tag pages 98 | const tagList = Array.from(tagSet) 99 | tagList.forEach((tag) => { 100 | createPage({ 101 | path: `/tag/${_.kebabCase(tag)}/`, 102 | component: tagTemplate, 103 | context: { 104 | tag, 105 | }, 106 | }) 107 | }) 108 | 109 | // Create posts 110 | const posts = items.filter((item) => item.node.fields.source === 'posts') 111 | posts.forEach(({ node }, index) => { 112 | const slug = node.fields.slug 113 | const next = index === 0 ? undefined : posts[index - 1].node 114 | const prev = index === posts.length - 1 ? undefined : posts[index + 1].node 115 | const source = node.fields.source 116 | 117 | createPage({ 118 | path: slug, 119 | component: postTemplate, 120 | context: { 121 | slug, 122 | prev, 123 | next, 124 | source, 125 | }, 126 | }) 127 | }) 128 | 129 | // and pages. 130 | const pages = items.filter((item) => item.node.fields.source === 'pages') 131 | pages.forEach(({ node }) => { 132 | const slug = node.fields.slug 133 | const source = node.fields.source 134 | 135 | createPage({ 136 | path: slug, 137 | component: pageTemplate, 138 | context: { 139 | slug, 140 | source, 141 | }, 142 | }) 143 | }) 144 | 145 | // Create blog post list pages 146 | const postsPerPage = 5 147 | const numPages = Math.ceil(posts.length / postsPerPage) 148 | 149 | _.times(numPages, (i) => { 150 | createPage({ 151 | path: i === 0 ? `/` : `/${i + 1}`, 152 | component: path.resolve('./src/templates/index.js'), 153 | context: { 154 | limit: postsPerPage, 155 | skip: i * postsPerPage, 156 | numPages, 157 | currentPage: i + 1, 158 | }, 159 | }) 160 | }) 161 | }) 162 | ) 163 | }) 164 | } 165 | -------------------------------------------------------------------------------- /jest-preprocess.js: -------------------------------------------------------------------------------- 1 | const babelOptions = { 2 | presets: ['babel-preset-gatsby'], 3 | } 4 | 5 | // eslint-disable-next-line import/no-extraneous-dependencies 6 | module.exports = require('babel-jest').createTransformer(babelOptions) 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(jsx|js)?$': `<rootDir>/jest-preprocess.js`, 4 | }, 5 | moduleNameMapper: { 6 | '.+\\.(css|styl|less|sass|scss)$': `<rootDir>/__mocks__/style-mock.js`, 7 | '.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': `<rootDir>/__mocks__/file-mock.js`, 8 | }, 9 | testPathIgnorePatterns: [`node_modules`, `.cache`, `cypress`, `__tests__/util`], 10 | transformIgnorePatterns: ['node_modules/(?!(gatsby|gatsby-plugin-mdx)/)'], 11 | globals: { 12 | __PATH_PREFIX__: ``, 13 | }, 14 | testURL: `http://localhost`, 15 | setupFiles: [`<rootDir>/loadershim.js`], 16 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], 17 | coverageReporters: ['json-summary', 'text', 'lcov'], 18 | } 19 | -------------------------------------------------------------------------------- /loadershim.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-underscore-dangle 2 | global.___loader = { 3 | enqueue: jest.fn(), 4 | } 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn run build" 3 | publish = "public" 4 | 5 | [[plugins]] 6 | package = "netlify-plugin-gatsby-cache" 7 | 8 | [[plugins]] 9 | package = "@sentry/netlify-build-plugin" 10 | 11 | [plugins.inputs] 12 | sentryOrg = "ottobot" 13 | sentryProject = "chrisottodev" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-chris-otto-dev", 3 | "version": "1.0.0", 4 | "author": "Chris Otto <chris.otto6@gmail.com>", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "GATSBY_EXPERIMENTAL_PAGE_BUILD_ON_DATA_CHANGES=true gatsby build --log-pages", 8 | "build:local": "gatsby build", 9 | "develop": "gatsby develop", 10 | "serve": "gatsby serve", 11 | "start": "gatsby build && gatsby serve", 12 | "format": "prettier --write 'src/**/*.js'", 13 | "lint": "eslint ./src", 14 | "lintFix": "eslint ./src --fix", 15 | "stylelint": "stylelint src/**/*.js", 16 | "test": "jest", 17 | "badges": "jest --coverage && jest-coverage-badges", 18 | "test:cypress": "cypress run", 19 | "e2e": "start-server-and-test serve http://localhost:9000 test:cypress", 20 | "e2e:open": "cypress open", 21 | "generate-app-icons": "sh ./scripts/generate-app-icons.sh", 22 | "post": "node util/newPost.js", 23 | "compress": "node util/compressImages.js" 24 | }, 25 | "dependencies": { 26 | "@mdx-js/mdx": "^1.6.22", 27 | "@mdx-js/react": "^1.6.22", 28 | "@testing-library/jest-dom": "^5.11.9", 29 | "@testing-library/react": "^11.2.5", 30 | "algoliasearch": "^4.8.5", 31 | "antd": "^4.12.3", 32 | "fontfaceobserver": "^2.0.13", 33 | "gatsby": "^2.32.3", 34 | "gatsby-image": "^2.11.0", 35 | "gatsby-plugin-algolia": "^0.16.3", 36 | "gatsby-plugin-catch-links": "^2.10.0", 37 | "gatsby-plugin-feed-mdx": "^1.0.1", 38 | "gatsby-plugin-google-analytics": "^2.11.0", 39 | "gatsby-plugin-layout": "^1.10.0", 40 | "gatsby-plugin-mailchimp": "^5.1.2", 41 | "gatsby-plugin-manifest": "^2.12.0", 42 | "gatsby-plugin-mdx": "^1.10.0", 43 | "gatsby-plugin-netlify": "^2.11.0", 44 | "gatsby-plugin-offline": "^3.10.0", 45 | "gatsby-plugin-react-helmet": "^3.10.0", 46 | "gatsby-plugin-react-helmet-canonical-urls": "^1.4.0", 47 | "gatsby-plugin-react-svg": "^3.0.0", 48 | "gatsby-plugin-robots-txt": "^1.5.5", 49 | "gatsby-plugin-sentry": "^1.0.1", 50 | "gatsby-plugin-sharp": "^2.14.1", 51 | "gatsby-plugin-sitemap": "^2.12.0", 52 | "gatsby-plugin-styled-jsx": "^3.10.0", 53 | "gatsby-plugin-styled-jsx-postcss": "^2.0.2", 54 | "gatsby-remark-autolink-headers": "^2.11.0", 55 | "gatsby-remark-copy-linked-files": "^2.10.0", 56 | "gatsby-remark-embedder": "^4.1.0", 57 | "gatsby-remark-emojis": "^0.4.3", 58 | "gatsby-remark-external-links": "^0.0.4", 59 | "gatsby-remark-images": "^3.11.0", 60 | "gatsby-remark-prismjs": "^3.13.0", 61 | "gatsby-remark-responsive-iframe": "^2.11.0", 62 | "gatsby-remark-smartypants": "^2.10.0", 63 | "gatsby-source-filesystem": "^2.11.0", 64 | "gatsby-transformer-json": "^2.11.0", 65 | "gatsby-transformer-sharp": "^2.12.0", 66 | "jest": "^26.6.3", 67 | "prismjs": "^1.22.0", 68 | "react": "^16.14.0", 69 | "react-dom": "^16.14.0", 70 | "react-helmet": "^6.1.0", 71 | "react-icons": "^4.2.0", 72 | "react-instantsearch-dom": "^6.9.0", 73 | "react-share": "^4.3.1", 74 | "react-visibility-sensor": "^5.1.1", 75 | "styled-jsx": "^3.3.2", 76 | "typeface-open-sans": "^1.1.13" 77 | }, 78 | "keywords": [ 79 | "gatsby", 80 | "blog", 81 | "personal website", 82 | "portfolio" 83 | ], 84 | "devDependencies": { 85 | "@testing-library/cypress": "^7.0.3", 86 | "axe-core": "^4.1.2", 87 | "babel-eslint": "^10.1.0", 88 | "babel-jest": "^26.6.3", 89 | "babel-preset-gatsby": "^0.12.1", 90 | "cypress": "^6.5.0", 91 | "cypress-axe": "^0.12.1", 92 | "dotenv": "^8.2.0", 93 | "eslint": "^7.20.0", 94 | "eslint-config-google": "^0.14.0", 95 | "eslint-config-prettier": "^7.2.0", 96 | "eslint-plugin-graphql": "^4.0.0", 97 | "eslint-plugin-import": "^2.22.1", 98 | "eslint-plugin-jsx-a11y": "^6.4.1", 99 | "eslint-plugin-prettier": "^3.3.0", 100 | "eslint-plugin-react": "^7.22.0", 101 | "glob": "^7.1.6", 102 | "husky": "^5.0.9", 103 | "lint-staged": "^10.5.4", 104 | "postcss": "^8.2.6", 105 | "postcss-cli": "^8.3.1", 106 | "postcss-cssnext": "^3.1.0", 107 | "postcss-custom-properties": "^11.0.0", 108 | "postcss-custom-selectors": "^6.0.0", 109 | "postcss-easy-media-query": "^1.0.0", 110 | "postcss-load-plugins": "^2.3.0", 111 | "postcss-loader": "^3.0.0", 112 | "postcss-media-variables": "^2.0.1", 113 | "postcss-nested": "^4.2.3", 114 | "postcss-sorting": "^6.0.0", 115 | "postcss-text-remove-gap": "^1.1.1", 116 | "postcss-utilities": "^0.8.4", 117 | "prettier": "^2.2.1", 118 | "react-test-renderer": "^16.14.0", 119 | "serve": "^11.3.2", 120 | "start-server-and-test": "^1.12.0", 121 | "stylelint": "^13.10.0", 122 | "tinify": "^1.6.0-beta.2" 123 | }, 124 | "husky": { 125 | "hooks": { 126 | "pre-commit": "lint-staged" 127 | } 128 | }, 129 | "lint-staged": { 130 | "*.{html,js,yml,mdx,md,json}": [ 131 | "prettier --write" 132 | ], 133 | "*.{js,jsx}": [ 134 | "yarn run lint" 135 | ], 136 | "*.{jpg,png,gif}": [ 137 | "yarn run compress", 138 | "git add ." 139 | ] 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ctx => ({ 2 | plugins: { 3 | "postcss-easy-media-query": { 4 | breakpoints: { 5 | tablet: 600, 6 | desktop: 1024 7 | } 8 | }, 9 | "postcss-text-remove-gap": { 10 | defaultFontFamily: "Open Sans", 11 | defaultLineHeight: "0" 12 | }, 13 | "postcss-nested": {}, 14 | "postcss-cssnext": {} 15 | } 16 | }); 17 | 18 | // "postcss-nested": {}, 19 | // "postcss-sorting": { 20 | // order: ["custom-properties", "dollar-variables", "declarations", "at-rules", "rules"], 21 | // "properties-order": "alphabetical", 22 | // "unspecified-properties-position": "bottom" 23 | // }, 24 | // "postcss-utilities": {}, 25 | // "postcss-cssnext": {} 26 | -------------------------------------------------------------------------------- /scripts/generate-app-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Exit the script on any command with non 0 return code 4 | set -e 5 | 6 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/favicon-16x16.png resize 16 7 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/favicon-32x32.png resize 32 8 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/favicon-96x96.png resize 96 9 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-512x512.png resize 512 10 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-384x384.png resize 384 11 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-256x256.png resize 256 12 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-192x192.png resize 192 13 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-144x144.png resize 144 14 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-96x96.png resize 96 15 | npx sharp -i ./src/images/app-icons/icon.png -o ./static/icons/icon-48x48.png resize 48 16 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-180x180.png resize 180 17 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-152x152.png resize 152 18 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-144x144.png resize 144 19 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-120x120.png resize 120 20 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-114x114.png resize 114 21 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-76x76.png resize 76 22 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-72x72.png resize 72 23 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-60x60.png resize 60 24 | npx sharp -i ./src/images/app-icons/apple-icon.png -o ./static/icons/apple-icon-57x57.png resize 57 25 | -------------------------------------------------------------------------------- /src/components/Article/Article.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Article = (props) => { 5 | const { children, theme } = props 6 | 7 | return ( 8 | <React.Fragment> 9 | <article className="article">{children}</article> 10 | 11 | {/* --- STYLES --- */} 12 | <style jsx>{` 13 | .article { 14 | padding: ${theme.space.inset.default}; 15 | margin: 0 auto; 16 | } 17 | @from-width tablet { 18 | .article { 19 | padding: ${`calc(${theme.space.default}) calc(${theme.space.default} * 2)`}; 20 | max-width: ${theme.text.maxWidth.tablet}; 21 | } 22 | } 23 | @from-width desktop { 24 | .article { 25 | padding: ${`calc(${theme.space.default} * 2 + 90px) 0 calc(${theme.space.default} * 2)`}; 26 | max-width: ${theme.text.maxWidth.desktop}; 27 | } 28 | } 29 | `}</style> 30 | </React.Fragment> 31 | ) 32 | } 33 | 34 | Article.propTypes = { 35 | children: PropTypes.node.isRequired, 36 | theme: PropTypes.object.isRequired, 37 | } 38 | 39 | export default Article 40 | -------------------------------------------------------------------------------- /src/components/Article/Bodytext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MDXRenderer } from 'gatsby-plugin-mdx' 3 | import PropTypes from 'prop-types' 4 | 5 | const Bodytext = (props) => { 6 | const { body, theme } = props 7 | 8 | return ( 9 | <React.Fragment> 10 | <div className="bodytext"> 11 | <MDXRenderer>{body}</MDXRenderer> 12 | </div> 13 | 14 | <style jsx>{` 15 | .bodytext { 16 | animation-name: bodytextEntry; 17 | animation-duration: ${theme.time.duration.long}; 18 | 19 | :global(h2), 20 | :global(h3) { 21 | margin: 1.5em 0 1em; 22 | } 23 | 24 | :global(h2) { 25 | line-height: ${theme.font.lineHeight.s}; 26 | font-size: ${theme.font.size.m}; 27 | } 28 | 29 | :global(h3) { 30 | font-size: ${theme.font.size.s}; 31 | line-height: ${theme.font.lineHeight.m}; 32 | } 33 | 34 | :global(p) { 35 | font-size: ${theme.font.size.s}; 36 | line-height: ${theme.font.lineHeight.xxl}; 37 | margin: 0 0 1.5em; 38 | } 39 | :global(ul) { 40 | list-style: circle; 41 | margin: 0 0 1.5em; 42 | padding: 0 0 0 1.5em; 43 | } 44 | :global(li) { 45 | margin: 0.7em 0; 46 | line-height: 1.5; 47 | } 48 | :global(a) { 49 | font-weight: ${theme.font.weight.bold}; 50 | color: ${theme.color.brand.primary}; 51 | text-decoration: underline; 52 | } 53 | :global(a.gatsby-resp-image-link) { 54 | border: 0; 55 | display: block; 56 | margin: 2.5em 0; 57 | border-radius: ${theme.size.radius.default}; 58 | overflow: hidden; 59 | border: 1px solid ${theme.line.color}; 60 | } 61 | :global(code.language-text) { 62 | background: ${theme.color.neutral.gray.c}; 63 | text-shadow: none; 64 | color: inherit; 65 | padding: 0.1em 0.3em 0.2em; 66 | border-radius: 0.1em; 67 | } 68 | } 69 | 70 | @keyframes bodytextEntry { 71 | from { 72 | opacity: 0; 73 | } 74 | to { 75 | opacity: 1; 76 | } 77 | } 78 | `}</style> 79 | </React.Fragment> 80 | ) 81 | } 82 | 83 | Bodytext.propTypes = { 84 | body: PropTypes.string.isRequired, 85 | theme: PropTypes.object.isRequired, 86 | } 87 | 88 | export default Bodytext 89 | -------------------------------------------------------------------------------- /src/components/Article/Headline.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Headline = (props) => { 5 | const { title, children, theme } = props 6 | 7 | return ( 8 | <React.Fragment> 9 | {title ? <h1 id={title}>{title}</h1> : <h1>{children}</h1>} 10 | 11 | {/* --- STYLES --- */} 12 | <style jsx>{` 13 | h1 { 14 | font-size: ${theme.font.size.l}; 15 | margin: ${theme.space.stack.l}; 16 | animation-name: headlineEntry; 17 | animation-duration: ${theme.time.duration.long}; 18 | 19 | :global(span) { 20 | font-weight: ${theme.font.weight.standard}; 21 | display: block; 22 | font-size: 0.5em; 23 | letter-spacing: 0; 24 | margin: ${theme.space.stack.xs}; 25 | } 26 | 27 | :global(svg) { 28 | height: 0.75em; 29 | fill: ${theme.color.brand.primary}; 30 | } 31 | } 32 | 33 | @keyframes headlineEntry { 34 | from { 35 | opacity: 0.5; 36 | } 37 | to { 38 | opacity: 1; 39 | } 40 | } 41 | 42 | @from-width tablet { 43 | h1 { 44 | font-size: ${`calc(${theme.font.size.xl} * 1.2)`}; 45 | } 46 | } 47 | 48 | @from-width desktop { 49 | h1 { 50 | font-size: ${`calc(${theme.font.size.xl} * 1.4)`}; 51 | } 52 | } 53 | `}</style> 54 | </React.Fragment> 55 | ) 56 | } 57 | 58 | Headline.propTypes = { 59 | title: PropTypes.string, 60 | children: PropTypes.node, 61 | theme: PropTypes.object.isRequired, 62 | } 63 | 64 | export default Headline 65 | -------------------------------------------------------------------------------- /src/components/Article/SubHeadline.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const SubHeadline = (props) => { 5 | const { title, children, theme } = props 6 | 7 | return ( 8 | <React.Fragment> 9 | {title ? <h2>{title}</h2> : <h2>{children}</h2>} 10 | 11 | {/* --- STYLES --- */} 12 | <style jsx>{` 13 | h2 { 14 | font-size: ${theme.font.size.m}; 15 | margin: ${theme.space.stack.m}; 16 | animation-name: headlineEntry; 17 | animation-duration: ${theme.time.duration.long}; 18 | 19 | :global(span) { 20 | font-weight: ${theme.font.weight.standard}; 21 | display: block; 22 | font-size: 0.5em; 23 | letter-spacing: 0; 24 | margin: ${theme.space.stack.xs}; 25 | } 26 | 27 | :global(svg) { 28 | height: 0.75em; 29 | fill: ${theme.color.brand.primary}; 30 | } 31 | } 32 | 33 | @keyframes headlineEntry { 34 | from { 35 | opacity: 0.5; 36 | } 37 | to { 38 | opacity: 1; 39 | } 40 | } 41 | 42 | @from-width tablet { 43 | h2 { 44 | font-size: ${`calc(${theme.font.size.m} * 1.2)`}; 45 | } 46 | } 47 | 48 | @from-width desktop { 49 | h2 { 50 | font-size: ${`calc(${theme.font.size.m} * 1.4)`}; 51 | } 52 | } 53 | `}</style> 54 | </React.Fragment> 55 | ) 56 | } 57 | 58 | SubHeadline.propTypes = { 59 | title: PropTypes.string, 60 | children: PropTypes.node, 61 | theme: PropTypes.object.isRequired, 62 | } 63 | 64 | export default SubHeadline 65 | -------------------------------------------------------------------------------- /src/components/Article/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Article' 2 | -------------------------------------------------------------------------------- /src/components/AsyncComponent/AsyncComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function asyncComponent(getComponent, loadingComponent) { 4 | return class AsyncComponent extends React.Component { 5 | state = { component: null } 6 | 7 | componentDidMount() { 8 | if (!this.state.component) { 9 | getComponent().then((component) => { 10 | this.setState({ component }) 11 | }) 12 | } 13 | } 14 | render() { 15 | const { component: Comp } = this.state 16 | if (Comp) { 17 | return <Comp {...this.props} /> 18 | } 19 | return loadingComponent ? loadingComponent : <div>Loading...</div> 20 | } 21 | } 22 | } 23 | 24 | export default asyncComponent 25 | -------------------------------------------------------------------------------- /src/components/AsyncComponent/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './AsyncComponent' 2 | -------------------------------------------------------------------------------- /src/components/Blog/Blog.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | import Item from './Item' 5 | 6 | const Blog = (props) => { 7 | const { posts, theme } = props 8 | 9 | return ( 10 | <React.Fragment> 11 | <section className="section"> 12 | <ul id="post-list"> 13 | {posts.map((post) => { 14 | const { 15 | node, 16 | node: { 17 | fields: { slug }, 18 | }, 19 | } = post 20 | return <Item key={slug} post={node} theme={theme} /> 21 | })} 22 | </ul> 23 | </section> 24 | 25 | {/* --- STYLES --- */} 26 | <style jsx>{` 27 | .section { 28 | padding: 0 ${theme.space.inset.default}; 29 | } 30 | 31 | ul { 32 | list-style: none; 33 | margin: 0 auto; 34 | padding: ${`calc(${theme.space.default} * 1.5) 0 calc(${theme.space.default} * 0.5)`}; 35 | } 36 | 37 | @above tablet { 38 | .section { 39 | padding: 0 ${`0 calc(${theme.space.default} * 1.5)`}; 40 | } 41 | ul { 42 | max-width: ${theme.text.maxWidth.tablet}; 43 | } 44 | } 45 | @above desktop { 46 | ul { 47 | max-width: ${theme.text.maxWidth.desktop}; 48 | } 49 | } 50 | `}</style> 51 | </React.Fragment> 52 | ) 53 | } 54 | 55 | Blog.propTypes = { 56 | posts: PropTypes.array.isRequired, 57 | theme: PropTypes.object.isRequired, 58 | } 59 | 60 | export default Blog 61 | -------------------------------------------------------------------------------- /src/components/Blog/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Blog' 2 | -------------------------------------------------------------------------------- /src/components/Contact/Contact.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars: 0 */ 2 | import { navigate } from 'gatsby' 3 | import { Form, Input, Button } from 'antd' 4 | import PropTypes from 'prop-types' 5 | import React from 'react' 6 | 7 | import 'antd/lib/form/style/index.css' 8 | import 'antd/lib/input/style/index.css' 9 | import 'antd/lib/button/style/index.css' 10 | import { ThemeContext } from '../../layouts' 11 | 12 | const { TextArea } = Input 13 | 14 | const ContactForm = (props) => { 15 | function encode(data) { 16 | return Object.keys(data) 17 | .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) 18 | .join('&') 19 | } 20 | 21 | function sendMessage(values) { 22 | fetch('/', { 23 | method: 'POST', 24 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 25 | body: encode({ 'form-name': 'contact', ...values }), 26 | }) 27 | .then(() => { 28 | console.log('Form submission success') 29 | navigate('/success') 30 | }) 31 | .catch((error) => { 32 | console.error('Form submission error:', error) 33 | this.handleNetworkError() 34 | }) 35 | } 36 | 37 | function handleNetworkError(e) { 38 | console.log('submit Error') 39 | } 40 | 41 | const layout = { 42 | labelCol: { span: 3 }, 43 | wrapperCol: { span: 16 }, 44 | } 45 | 46 | const tailLayout = { 47 | wrapperCol: { offset: 3, span: 12 }, 48 | } 49 | 50 | return ( 51 | <React.Fragment> 52 | <ThemeContext.Consumer> 53 | {(theme) => ( 54 | <div className="form"> 55 | <Form 56 | {...layout} 57 | name="contact" 58 | onFinish={sendMessage} 59 | data-netlify="true" 60 | data-netlify-honeypot="bot-field" 61 | > 62 | <Form.Item name="name" label="Name" rules={[{ whitespace: true }]}> 63 | <Input name="name" aria-label="name" /> 64 | </Form.Item> 65 | <Form.Item 66 | label="E-mail" 67 | name="email" 68 | rules={[ 69 | { 70 | required: true, 71 | message: 'Please input your e-mail address!', 72 | whitespace: true, 73 | type: 'email', 74 | }, 75 | ]} 76 | > 77 | <Input name="email" aria-label="email" /> 78 | </Form.Item> 79 | <Form.Item 80 | label="Message" 81 | name="message" 82 | rules={[ 83 | { required: true, message: 'Please input your message!', whitespace: true }, 84 | ]} 85 | > 86 | <TextArea 87 | name="message" 88 | aria-label="message" 89 | placeholder="" 90 | autoSize={{ minRows: 4, maxRows: 10 }} 91 | /> 92 | </Form.Item> 93 | <Form.Item {...tailLayout}> 94 | <Button type="primary" shape="round" htmlType="submit"> 95 | Submit 96 | </Button> 97 | </Form.Item> 98 | </Form> 99 | 100 | {/* --- STYLES --- */} 101 | <style jsx>{` 102 | .form { 103 | background: transparent; 104 | } 105 | .form :global(.ant-row.ant-form-item) { 106 | margin: 0 0 1em; 107 | } 108 | .form :global(.ant-row.ant-form-item:last-child) { 109 | margin-top: 1em; 110 | } 111 | .form :global(.ant-form-item-control) { 112 | line-height: 1em; 113 | } 114 | .form :global(.ant-form-item-label) { 115 | line-height: 1em; 116 | margin-bottom: 0.5em; 117 | } 118 | .form :global(.ant-form-item) { 119 | margin: 0; 120 | } 121 | .form :global(.ant-input) { 122 | appearance: none; 123 | height: auto; 124 | font-size: 1.2em; 125 | padding: 0.5em 0.6em; 126 | } 127 | .form :global(.ant-btn-primary) { 128 | height: auto; 129 | font-size: 1.2em; 130 | padding: 0.5em 3em; 131 | background: ${theme.color.brand.primary}; 132 | border: 1px solid ${theme.color.brand.primary}; 133 | } 134 | .form :global(.ant-form-explain) { 135 | margin-top: 0.2em; 136 | } 137 | 138 | @from-width desktop { 139 | .form :global(input) { 140 | max-width: 50%; 141 | } 142 | } 143 | `}</style> 144 | </div> 145 | )} 146 | </ThemeContext.Consumer> 147 | </React.Fragment> 148 | ) 149 | } 150 | 151 | ContactForm.propTypes = { 152 | form: PropTypes.object, 153 | } 154 | 155 | export default ContactForm 156 | -------------------------------------------------------------------------------- /src/components/Contact/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Contact' 2 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Sentry from 'gatsby-plugin-sentry' 4 | 5 | class ErrorBoundary extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { error: null } 9 | } 10 | 11 | componentDidCatch(error, errorInfo) { 12 | this.setState({ error }) 13 | Sentry.configureScope((scope) => { 14 | Object.keys(errorInfo).forEach((key) => { 15 | scope.setExtra(key, errorInfo[key]) 16 | }) 17 | }) 18 | Sentry.captureException(error) 19 | } 20 | 21 | render() { 22 | if (this.state.error) { 23 | // render fallback UI 24 | return <h1>Something went wrong!</h1> 25 | } else { 26 | // when there's not an error, render children untouched 27 | return this.props.children 28 | } 29 | } 30 | } 31 | 32 | ErrorBoundary.propTypes = { 33 | children: PropTypes.object.isRequired, 34 | } 35 | 36 | export default ErrorBoundary 37 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './ErrorBoundary' 2 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Social from '../Social' 4 | 5 | const Footer = (props) => { 6 | const { theme } = props 7 | 8 | const year = new Date().getFullYear() 9 | 10 | return ( 11 | <React.Fragment> 12 | <footer className="footer" role="contentinfo"> 13 | <Social theme={theme} /> 14 | <ul> 15 | <li> 16 | © {year} Chris Otto | Hosted on{' '} 17 | <a 18 | href="https://www.netlify.com" 19 | target="_blank" 20 | rel="noopener noreferrer" 21 | title="Netlify" 22 | > 23 | Netlify 24 | </a>{' '} 25 | | Built with{' '} 26 | <a 27 | href="https://www.gatsbyjs.org" 28 | target="_blank" 29 | rel="noopener noreferrer" 30 | title="Gatsby" 31 | > 32 | Gatsby 33 | </a> 34 | </li> 35 | </ul> 36 | </footer> 37 | 38 | {/* --- STYLES --- */} 39 | <style jsx>{` 40 | .footer { 41 | background: ${theme.color.neutral.white}; 42 | padding: ${theme.space.inset.default}; 43 | padding-top: 0; 44 | padding-bottom: 120px; 45 | 46 | :global(ul) { 47 | list-style: none; 48 | text-align: center; 49 | padding: 0; 50 | 51 | :global(li) { 52 | color: ${theme.color.neutral.gray.g}; 53 | font-size: ${theme.font.size.xxs}; 54 | padding: ${theme.space.xxs} ${theme.space.s}; 55 | position: relative; 56 | display: inline-block; 57 | 58 | &::after { 59 | content: '•'; 60 | position: absolute; 61 | right: ${`calc(${theme.space.xs} * -1)`}; 62 | } 63 | &:last-child::after { 64 | content: ''; 65 | } 66 | } 67 | } 68 | } 69 | 70 | @from-width desktop { 71 | .footer { 72 | padding: 0 1em 1.5em; 73 | } 74 | } 75 | `}</style> 76 | </React.Fragment> 77 | ) 78 | } 79 | 80 | Footer.propTypes = { 81 | theme: PropTypes.object.isRequired, 82 | } 83 | 84 | export default Footer 85 | -------------------------------------------------------------------------------- /src/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Footer' 2 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Header' 2 | -------------------------------------------------------------------------------- /src/components/Hero/Hero.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { FaArrowDown } from 'react-icons/fa/' 5 | 6 | const Hero = (props) => { 7 | const { scrollToContent, backgrounds, theme } = props 8 | 9 | return ( 10 | <React.Fragment> 11 | <section className="hero"> 12 | <h1>Hi! I’m Chris.</h1> 13 | <p> 14 | I’m a software engineer based in Milwaukee, WI. 15 | <br />I enjoy Javascript, DevOps and Testing. 16 | </p> 17 | <button onClick={scrollToContent} aria-label="scroll"> 18 | <FaArrowDown /> 19 | </button> 20 | </section> 21 | 22 | {/* --- STYLES --- */} 23 | <style jsx>{` 24 | .hero { 25 | align-items: center; 26 | background: ${theme.hero.background}; 27 | background-image: url(${backgrounds.mobile}); 28 | background-size: cover; 29 | color: ${theme.text.color.primary.inverse}; 30 | display: flex; 31 | flex-flow: column nowrap; 32 | justify-content: center; 33 | min-height: 100vh; 34 | height: 100px; 35 | padding: ${theme.space.inset.l}; 36 | padding-top: ${theme.header.height.homepage}; 37 | } 38 | 39 | h1 { 40 | text-align: center; 41 | font-size: ${theme.hero.h1.size}; 42 | margin: ${theme.space.stack.l}; 43 | color: ${theme.hero.h1.color}; 44 | line-height: ${theme.hero.h1.lineHeight}; 45 | text-remove-gap: both 0 'Open Sans'; 46 | } 47 | 48 | p { 49 | text-align: center; 50 | font-size: ${theme.font.size.m}; 51 | margin: ${theme.space.stack.m}; 52 | color: ${theme.hero.h1.color}; 53 | line-height: ${theme.font.lineHeight.xl}; 54 | text-remove-gap: both 0 'Open Sans'; 55 | } 56 | 57 | button { 58 | background: ${theme.background.color.brand}; 59 | border: 0; 60 | border-radius: 50%; 61 | font-size: ${theme.font.size.m}; 62 | padding: ${theme.space.s} ${theme.space.m}; 63 | cursor: pointer; 64 | width: ${theme.space.xl}; 65 | height: ${theme.space.xl}; 66 | 67 | &:focus { 68 | outline-style: none; 69 | background: ${theme.color.brand.primary.active}; 70 | } 71 | 72 | :global(svg) { 73 | position: relative; 74 | top: 5px; 75 | fill: ${theme.color.neutral.white}; 76 | stroke-width: 40; 77 | stroke: ${theme.color.neutral.white}; 78 | animation-duration: ${theme.time.duration.long}; 79 | animation-name: buttonIconMove; 80 | animation-iteration-count: infinite; 81 | } 82 | } 83 | 84 | @keyframes buttonIconMove { 85 | 0% { 86 | transform: translateY(0); 87 | } 88 | 50% { 89 | transform: translateY(-10px); 90 | } 91 | 100% { 92 | transform: translateY(0); 93 | } 94 | } 95 | 96 | @from-width tablet { 97 | .hero { 98 | background-image: url(${backgrounds.tablet}); 99 | } 100 | 101 | h1 { 102 | max-width: 90%; 103 | font-size: ${`calc(${theme.hero.h1.size} * 1.3)`}; 104 | } 105 | 106 | button { 107 | font-size: ${theme.font.size.l}; 108 | } 109 | } 110 | 111 | @from-width desktop { 112 | .hero { 113 | background-image: url(${backgrounds.desktop}); 114 | } 115 | 116 | h1 { 117 | max-width: 80%; 118 | font-size: ${`calc(${theme.hero.h1.size} * 1.5)`}; 119 | } 120 | 121 | button { 122 | font-size: ${theme.font.size.xl}; 123 | } 124 | } 125 | `}</style> 126 | </React.Fragment> 127 | ) 128 | } 129 | 130 | Hero.propTypes = { 131 | scrollToContent: PropTypes.func.isRequired, 132 | backgrounds: PropTypes.object.isRequired, 133 | theme: PropTypes.object.isRequired, 134 | } 135 | 136 | export default Hero 137 | -------------------------------------------------------------------------------- /src/components/Hero/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Hero' 2 | -------------------------------------------------------------------------------- /src/components/List/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | const List = (props) => { 6 | const { edges, theme } = props 7 | 8 | return ( 9 | <React.Fragment> 10 | <ul> 11 | {edges.map((edge) => { 12 | const { 13 | node: { 14 | frontmatter: { title }, 15 | fields: { slug }, 16 | }, 17 | } = edge 18 | 19 | return ( 20 | <li key={slug}> 21 | <Link to={slug}>{title}</Link> 22 | </li> 23 | ) 24 | })} 25 | </ul> 26 | 27 | {/* --- STYLES --- */} 28 | <style jsx>{` 29 | ul { 30 | margin: ${theme.space.stack.m}; 31 | padding: ${theme.space.m}; 32 | list-style: circle; 33 | } 34 | li { 35 | padding: ${theme.space.xs} 0; 36 | font-size: ${theme.font.size.s}; 37 | line-height: ${theme.font.lineHeight.l}; 38 | } 39 | `}</style> 40 | </React.Fragment> 41 | ) 42 | } 43 | 44 | List.propTypes = { 45 | edges: PropTypes.array.isRequired, 46 | theme: PropTypes.object.isRequired, 47 | } 48 | 49 | export default List 50 | -------------------------------------------------------------------------------- /src/components/List/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './List' 2 | -------------------------------------------------------------------------------- /src/components/Menu/Expand.js: -------------------------------------------------------------------------------- 1 | import { FaAngleDown } from 'react-icons/fa/' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | 5 | const Expand = (props) => { 6 | const { onClick, theme } = props 7 | 8 | return ( 9 | <React.Fragment> 10 | <button className="more" to="#" onClick={onClick} aria-label="expand"> 11 | <FaAngleDown size={30} /> 12 | </button> 13 | 14 | {/* --- STYLES --- */} 15 | <style jsx>{` 16 | .more { 17 | cursor: pointer; 18 | } 19 | 20 | @below desktop { 21 | .more { 22 | background: ${theme.color.neutral.white}; 23 | border: 1px solid ${theme.color.brand.primary}; 24 | border-radius: ${theme.size.radius.small} ${theme.size.radius.small} 0 0; 25 | border-bottom: none; 26 | position: absolute; 27 | left: 50%; 28 | top: -35px; 29 | width: 60px; 30 | height: 36px; 31 | overflow: hidden; 32 | z-index: 1; 33 | transform: translateX(-50%); 34 | 35 | &:focus { 36 | outline: none; 37 | 38 | :global(svg) { 39 | fill: ${theme.color.brand.primary}; 40 | } 41 | } 42 | 43 | :global(svg) { 44 | transition: all 0.5s; 45 | transform: rotateZ(180deg); 46 | fill: ${theme.color.special.attention}; 47 | } 48 | 49 | :global(.open) & :global(svg) { 50 | transform: rotateZ(0deg); 51 | } 52 | } 53 | } 54 | 55 | @from-width desktop { 56 | .more { 57 | flex-shrink: 0; 58 | flex-grow: 0; 59 | width: 44px; 60 | height: 38px; 61 | background: transparent; 62 | margin-left: 10px; 63 | border-radius: ${theme.size.radius.small}; 64 | border: 1px solid ${theme.line.color}; 65 | display: flex; 66 | transition: background-color ${theme.time.duration.default}; 67 | justify-content: center; 68 | align-items: center; 69 | padding: 0; 70 | z-index: 1; 71 | 72 | &:focus, 73 | &:hover { 74 | outline: none; 75 | } 76 | 77 | :global(svg) { 78 | transition: all ${theme.time.duration.default}; 79 | } 80 | 81 | :global(.homepage) & { 82 | border: 1px solid transparent; 83 | background-color: color(white alpha(-90%)); 84 | 85 | &:hover { 86 | background-color: color(white alpha(-60%)); 87 | } 88 | } 89 | 90 | :global(.open) & { 91 | background-color: color(white alpha(-10%)); 92 | border-bottom-left-radius: 0; 93 | border-bottom-right-radius: 0; 94 | 95 | &:hover { 96 | background-color: color(white alpha(-10%)); 97 | } 98 | 99 | :global(svg) { 100 | transform: rotate(180deg); 101 | } 102 | } 103 | 104 | :global(.fixed) & { 105 | border: 1px solid ${theme.line.color}; 106 | height: 30px; 107 | } 108 | } 109 | } 110 | `}</style> 111 | </React.Fragment> 112 | ) 113 | } 114 | 115 | Expand.propTypes = { 116 | onClick: PropTypes.func, 117 | theme: PropTypes.object.isRequired, 118 | } 119 | 120 | export default Expand 121 | -------------------------------------------------------------------------------- /src/components/Menu/Item.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | const Item = (props) => { 6 | const { theme, item: { label, to, icon: Icon } = {}, onClick } = props 7 | 8 | return ( 9 | <React.Fragment> 10 | <li className={'hiddenItem' in props ? 'hiddenItem' : 'item'} key={label}> 11 | <Link 12 | to={to} 13 | className={'hiddenItem' in props ? 'inHiddenItem' : ''} 14 | onClick={onClick} 15 | data-slug={to} 16 | > 17 | {Icon && <Icon />} {label} 18 | </Link> 19 | </li> 20 | 21 | {/* --- STYLES --- */} 22 | <style jsx>{` 23 | .item, 24 | .showItem { 25 | background: transparent; 26 | transition: all ${theme.time.duration.default}; 27 | display: flex; 28 | align-items: center; 29 | 30 | :global(a) { 31 | padding: ${theme.space.inset.s}; 32 | display: flex; 33 | align-items: center; 34 | } 35 | 36 | :global(svg) { 37 | margin: 0 ${theme.space.inset.xs} 0 0; 38 | opacity: 0.3; 39 | } 40 | } 41 | 42 | :global(.itemList .hideItem) { 43 | display: none; 44 | } 45 | 46 | @from-width desktop { 47 | .item { 48 | :global(a) { 49 | color: ${theme.text.color.primary}; 50 | padding: ${theme.space.inset.s}; 51 | transition: all ${theme.time.duration.default}; 52 | border-radius: ${theme.size.radius.small}; 53 | } 54 | 55 | :global(.homepage):not(.fixed) & :global(a) { 56 | color: ${theme.color.neutral.white}; 57 | } 58 | 59 | :global(a:hover) { 60 | color: ${theme.color.brand.primary}; 61 | background: color(white alpha(-60%)); 62 | } 63 | 64 | :global(svg) { 65 | transition: all ${theme.time.duration.default}; 66 | } 67 | 68 | &:hover :global(svg) { 69 | fill: ${theme.color.brand.primary}; 70 | opacity: 1; 71 | 72 | :global(.hero) & :global(svg) { 73 | fill: green; 74 | } 75 | } 76 | } 77 | 78 | .showItem { 79 | display: none; 80 | } 81 | 82 | .hiddenItem { 83 | text-align: left; 84 | padding: ${theme.space.xs}; 85 | 86 | & :global(a.inHiddenItem) { 87 | color: ${theme.text.color.primary}; 88 | &:hover { 89 | color: ${theme.color.brand.primary}; 90 | } 91 | } 92 | } 93 | } 94 | `}</style> 95 | </React.Fragment> 96 | ) 97 | } 98 | 99 | Item.propTypes = { 100 | item: PropTypes.object, 101 | hidden: PropTypes.bool, 102 | onClick: PropTypes.func, 103 | icon: PropTypes.func, 104 | theme: PropTypes.object.isRequired, 105 | } 106 | 107 | export default Item 108 | -------------------------------------------------------------------------------- /src/components/Menu/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Menu' 2 | -------------------------------------------------------------------------------- /src/components/Page/Page.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Headline from '../Article/Headline' 5 | import Bodytext from '../Article/Bodytext' 6 | 7 | const Page = (props) => { 8 | const { 9 | page: { 10 | body, 11 | frontmatter: { title }, 12 | }, 13 | theme, 14 | } = props 15 | 16 | return ( 17 | <React.Fragment> 18 | <header> 19 | <Headline title={title} theme={theme} /> 20 | </header> 21 | <Bodytext body={body} theme={theme} /> 22 | </React.Fragment> 23 | ) 24 | } 25 | 26 | Page.propTypes = { 27 | page: PropTypes.object.isRequired, 28 | theme: PropTypes.object.isRequired, 29 | } 30 | 31 | export default Page 32 | -------------------------------------------------------------------------------- /src/components/Page/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Page' 2 | -------------------------------------------------------------------------------- /src/components/Post/Author.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MDXRenderer } from 'gatsby-plugin-mdx' 3 | import PropTypes from 'prop-types' 4 | 5 | import config from '../../../content/meta/config' 6 | import avatar from '../../images/jpg/avatar.jpg' 7 | 8 | const Author = (props) => { 9 | const { note, theme } = props 10 | 11 | return ( 12 | <React.Fragment> 13 | <div className="author"> 14 | <div className="avatar"> 15 | <img 16 | src={config.gravatarImgMd5 == '' ? avatar : config.gravatarImgMd5} 17 | alt={config.siteTitle} 18 | /> 19 | </div> 20 | <div className="note"> 21 | <MDXRenderer>{note}</MDXRenderer> 22 | </div> 23 | </div> 24 | 25 | {/* --- STYLES --- */} 26 | <style jsx>{` 27 | .author { 28 | margin: ${theme.space.l} 0; 29 | padding: ${theme.space.l} 0; 30 | border-top: 1px solid ${theme.line.color}; 31 | border-bottom: 1px solid ${theme.line.color}; 32 | } 33 | .avatar { 34 | float: left; 35 | border-radius: 65% 75%; 36 | border: 1px solid ${theme.line.color}; 37 | display: inline-block; 38 | height: 50px; 39 | margin: 5px 20px 0 0; 40 | overflow: hidden; 41 | width: 50px; 42 | } 43 | .avatar img { 44 | width: 100%; 45 | } 46 | .note { 47 | font-size: 0.9em; 48 | line-height: 1.6; 49 | text-align: center; 50 | margin: auto; 51 | } 52 | @from-width tablet { 53 | .author { 54 | display: flex; 55 | } 56 | .avatar { 57 | flex: 0 0 auto; 58 | } 59 | } 60 | `}</style> 61 | </React.Fragment> 62 | ) 63 | } 64 | 65 | Author.propTypes = { 66 | note: PropTypes.string.isRequired, 67 | theme: PropTypes.object.isRequired, 68 | } 69 | 70 | export default Author 71 | -------------------------------------------------------------------------------- /src/components/Post/Meta.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | import { FaCalendar } from 'react-icons/fa/' 6 | import { FaRegClock } from 'react-icons/fa/' 7 | import { FaUser } from 'react-icons/fa/' 8 | import { FaTag } from 'react-icons/fa/' 9 | 10 | const Meta = (props) => { 11 | const { prefix, author: authorName, tags, timeToRead, theme } = props 12 | 13 | return ( 14 | <p className="meta"> 15 | <span> 16 | <FaCalendar size={10} /> {prefix} 17 | </span> 18 | <span> 19 | <FaUser size={10} /> {authorName} 20 | </span> 21 | <span> 22 | <FaRegClock size={10} /> {timeToRead} min. 23 | </span> 24 | {tags && 25 | tags.map((tag) => ( 26 | <span key={tag}> 27 | <FaTag size={10} /> 28 | <Link to={`/tag/${tag.split(' ').join('-')}`}>{tag}</Link> 29 | </span> 30 | ))} 31 | 32 | {/* --- STYLES --- */} 33 | <style jsx>{` 34 | .meta { 35 | display: flex; 36 | flex-flow: row wrap; 37 | font-size: 0.8em; 38 | margin: ${theme.space.m} 0; 39 | background: transparent; 40 | 41 | :global(svg) { 42 | fill: ${theme.icon.color}; 43 | margin: ${theme.space.inline.xs}; 44 | } 45 | span { 46 | align-items: center; 47 | display: flex; 48 | text-transform: uppercase; 49 | margin: ${theme.space.xs} ${theme.space.s} ${theme.space.xs} 0; 50 | } 51 | } 52 | @from-width tablet { 53 | .meta { 54 | margin: ${`calc(${theme.space.m} * 1.5) 0 ${theme.space.m}`}; 55 | } 56 | } 57 | `}</style> 58 | </p> 59 | ) 60 | } 61 | 62 | Meta.propTypes = { 63 | prefix: PropTypes.string.isRequired, 64 | author: PropTypes.string.isRequired, 65 | category: PropTypes.string, 66 | timeToRead: PropTypes.string, 67 | theme: PropTypes.object.isRequired, 68 | tags: PropTypes.object.isRequired, 69 | } 70 | 71 | export default Meta 72 | -------------------------------------------------------------------------------- /src/components/Post/NextPrev.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | import { FaArrowRight } from 'react-icons/fa/' 6 | import { FaArrowLeft } from 'react-icons/fa/' 7 | 8 | const NextPrev = (props) => { 9 | const { 10 | theme, 11 | next: { 12 | fields: { prefix: nextPrefix, slug: nextSlug } = {}, 13 | frontmatter: { title: nextTitle } = {}, 14 | } = {}, 15 | prev: { 16 | fields: { prefix: prevPrefix, slug: prevSlug } = {}, 17 | frontmatter: { title: prevTitle } = {}, 18 | } = {}, 19 | } = props 20 | 21 | return ( 22 | <React.Fragment> 23 | <div className="links"> 24 | {nextSlug && ( 25 | <Link to={nextSlug}> 26 | <FaArrowRight /> 27 | <h4> 28 | {nextTitle} <time>{nextPrefix} </time> 29 | </h4> 30 | </Link> 31 | )} 32 | {prevSlug && ( 33 | <Link to={prevSlug}> 34 | <FaArrowLeft /> 35 | <h4> 36 | {prevTitle} <time>{prevPrefix}</time> 37 | </h4> 38 | </Link> 39 | )} 40 | </div> 41 | 42 | {/* --- STYLES --- */} 43 | <style jsx>{` 44 | .links { 45 | display: flex; 46 | flex-direction: column; 47 | padding: 0 ${theme.space.m} ${theme.space.l}; 48 | border-bottom: 1px solid ${theme.line.color}; 49 | margin: ${theme.space.stack.l}; 50 | 51 | :global(a) { 52 | display: flex; 53 | } 54 | 55 | :global(a:nth-child(2)) { 56 | margin: ${theme.space.default} 0 0; 57 | } 58 | 59 | :global(svg) { 60 | fill: ${theme.color.special.attention}; 61 | width: ${theme.space.m}; 62 | height: ${theme.space.m}; 63 | flex-shrink: 0; 64 | flex-grow: 0; 65 | margin: ${theme.space.inline.m}; 66 | } 67 | } 68 | 69 | h4 { 70 | font-weight: 600; 71 | margin: 0; 72 | font-size: 1.1em; 73 | } 74 | time { 75 | color: ${theme.color.neutral.gray.g}; 76 | display: block; 77 | font-weight: 400; 78 | font-size: 0.8em; 79 | margin-top: 0.5em; 80 | } 81 | 82 | @from-width desktop { 83 | .links { 84 | flex-direction: row-reverse; 85 | justify-content: center; 86 | 87 | :global(a) { 88 | flex-basis: 50%; 89 | } 90 | 91 | :global(a:nth-child(2)) { 92 | margin: 0; 93 | } 94 | :global(svg) { 95 | transition: all 0.5s; 96 | margin: ${theme.space.inline.s}; 97 | } 98 | } 99 | 100 | @media (hover: hover) { 101 | .links :global(a:hover svg) { 102 | transform: scale(1.5); 103 | } 104 | } 105 | } 106 | `}</style> 107 | </React.Fragment> 108 | ) 109 | } 110 | 111 | NextPrev.propTypes = { 112 | next: PropTypes.object, 113 | prev: PropTypes.object, 114 | theme: PropTypes.object.isRequired, 115 | } 116 | 117 | export default NextPrev 118 | -------------------------------------------------------------------------------- /src/components/Post/Post.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import 'prismjs/themes/prism-okaidia.css' 4 | import Img from 'gatsby-image' 5 | 6 | import asyncComponent from '../AsyncComponent' 7 | import Headline from '../Article/Headline' 8 | import Bodytext from '../Article/Bodytext' 9 | import Subscribe from '../Subscribe' 10 | import Meta from './Meta' 11 | import Author from './Author' 12 | import NextPrev from './NextPrev' 13 | 14 | const Share = asyncComponent(() => 15 | import('./Share') 16 | .then((module) => { 17 | return module.default 18 | }) 19 | .catch((error) => {}) 20 | ) 21 | 22 | const Post = (props) => { 23 | const { 24 | post, 25 | post: { 26 | timeToRead, 27 | body, 28 | fields: { prefix }, 29 | frontmatter: { 30 | title, 31 | author, 32 | tags, 33 | cover: { 34 | children: [{ fluid }], 35 | }, 36 | }, 37 | }, 38 | authornote, 39 | next: nextPost, 40 | prev: prevPost, 41 | theme, 42 | } = props 43 | 44 | return ( 45 | <React.Fragment> 46 | <header> 47 | <Headline title={title} theme={theme} /> 48 | <Img fluid={fluid} /> 49 | <Meta prefix={prefix} author={author} tags={tags} timeToRead={timeToRead} theme={theme} /> 50 | </header> 51 | <Bodytext body={body} theme={theme} /> 52 | <footer> 53 | <Share post={post} theme={theme} /> 54 | <Subscribe /> 55 | <Author note={authornote} theme={theme} /> 56 | <NextPrev next={nextPost} prev={prevPost} theme={theme} /> 57 | </footer> 58 | </React.Fragment> 59 | ) 60 | } 61 | 62 | Post.propTypes = { 63 | post: PropTypes.object.isRequired, 64 | authornote: PropTypes.string.isRequired, 65 | next: PropTypes.object, 66 | prev: PropTypes.object, 67 | theme: PropTypes.object.isRequired, 68 | } 69 | 70 | export default Post 71 | -------------------------------------------------------------------------------- /src/components/Post/Share.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | FacebookShareButton, 5 | LinkedinShareButton, 6 | TwitterShareButton, 7 | FacebookShareCount, 8 | FacebookIcon, 9 | TwitterIcon, 10 | LinkedinIcon, 11 | } from 'react-share' 12 | 13 | import config from '../../../content/meta/config' 14 | 15 | const PostShare = (props) => { 16 | const { 17 | post: { 18 | fields: { slug }, 19 | frontmatter: { title }, 20 | excerpt, 21 | }, 22 | theme, 23 | } = props 24 | 25 | const url = config.siteUrl + slug 26 | 27 | const iconSize = 36 28 | const filter = (count) => (count > 0 ? count : '') 29 | 30 | return ( 31 | <React.Fragment> 32 | <div className="share"> 33 | <span className="label">SHARE</span> 34 | <div className="links"> 35 | <TwitterShareButton 36 | url={url} 37 | title={title} 38 | additionalProps={{ 39 | 'aria-label': 'Twitter share', 40 | }} 41 | > 42 | <TwitterIcon round size={iconSize} /> 43 | </TwitterShareButton> 44 | <FacebookShareButton 45 | url={url} 46 | quote={`${title} - ${excerpt}`} 47 | additionalProps={{ 48 | 'aria-label': 'Facebook share', 49 | }} 50 | > 51 | <FacebookIcon round size={iconSize} /> 52 | <FacebookShareCount url={url}> 53 | {(count) => <div className="share-count">{filter(count)}</div>} 54 | </FacebookShareCount> 55 | </FacebookShareButton> 56 | <LinkedinShareButton 57 | url={url} 58 | title={title} 59 | description={excerpt} 60 | additionalProps={{ 61 | 'aria-label': 'LinkedIn share', 62 | }} 63 | > 64 | <LinkedinIcon round size={iconSize} /> 65 | </LinkedinShareButton> 66 | </div> 67 | </div> 68 | 69 | {/* --- STYLES --- */} 70 | <style jsx>{` 71 | .share { 72 | display: flex; 73 | flex-direction: column; 74 | justify-content: center; 75 | align-items: center; 76 | } 77 | 78 | .links { 79 | display: flex; 80 | flex-direction: row; 81 | :global(.react-share__ShareButton) { 82 | margin: 0 0.8em; 83 | cursor: pointer; 84 | } 85 | } 86 | 87 | .label { 88 | font-size: 1.2em; 89 | margin: 0 1em 1em; 90 | } 91 | 92 | @from-width tablet { 93 | .share { 94 | flex-direction: row; 95 | margin: ${theme.space.inset.l}; 96 | } 97 | .label { 98 | margin: ${theme.space.inline.m}; 99 | } 100 | } 101 | `}</style> 102 | </React.Fragment> 103 | ) 104 | } 105 | 106 | PostShare.propTypes = { 107 | post: PropTypes.object.isRequired, 108 | theme: PropTypes.object.isRequired, 109 | } 110 | 111 | export default PostShare 112 | -------------------------------------------------------------------------------- /src/components/Post/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Post' 2 | -------------------------------------------------------------------------------- /src/components/Project/Project.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Img from 'gatsby-image' 4 | import { FaGithub } from 'react-icons/fa/' 5 | import { FaExternalLinkAlt } from 'react-icons/fa/' 6 | 7 | const Project = (props) => { 8 | const { 9 | theme, 10 | project: { name, description, img, tech, githuburl, projecturl }, 11 | images, 12 | } = props 13 | 14 | const image = images.find((x) => { 15 | return x.node.relativePath === `project/${img}` 16 | }) 17 | 18 | return ( 19 | <React.Fragment> 20 | <li className="card"> 21 | <header> 22 | <div> 23 | <Img fluid={image.node.childImageSharp.fluid} title={name} alt="Project image" /> 24 | </div> 25 | <h2>{name}</h2> 26 | </header> 27 | <section> 28 | <p>{description}</p> 29 | {tech.map((item) => ( 30 | <span key={item}>{item}</span> 31 | ))} 32 | </section> 33 | <footer> 34 | {projecturl && ( 35 | <a href={projecturl} target="_blank" rel="noopener noreferrer" title={name}> 36 | <FaExternalLinkAlt size={28} /> 37 | </a> 38 | )} 39 | {githuburl && ( 40 | <a href={githuburl} target="_blank" rel="noopener noreferrer" title={name}> 41 | <FaGithub size={28} /> 42 | </a> 43 | )} 44 | </footer> 45 | </li> 46 | 47 | {/* --- STYLES --- */} 48 | <style jsx>{` 49 | .card { 50 | display: flex; 51 | overflow: hidden; 52 | list-style: none; 53 | flex-direction: column; 54 | border-radius: ${theme.space.s}; 55 | background: ${theme.color.neutral.gray.b}; 56 | header { 57 | div { 58 | position: relative; 59 | gatsby-image-wrapper { 60 | display: block; 61 | margin-left: auto; 62 | margin-right: auto; 63 | top: 0; 64 | width: 100%; 65 | position: absolute; 66 | } 67 | } 68 | h2 { 69 | font-weight: 500; 70 | font-size: 24px; 71 | padding: 0 ${theme.space.m}; 72 | margin: ${theme.space.m} 0; 73 | @media (max-width: 567px) { 74 | padding: 0 ${theme.space.m}; 75 | } 76 | } 77 | } 78 | section { 79 | padding: 0 ${theme.space.m}; 80 | height: 100%; 81 | @media (max-width: 567px) { 82 | padding: 0 ${theme.space.s}; 83 | } 84 | p { 85 | font-size: 16px; 86 | line-height: 27px; 87 | margin-bottom: 12px; 88 | @media (max-width: 567px) { 89 | font-weight: 300; 90 | } 91 | } 92 | span { 93 | margin-right: 5px; 94 | font-size: 90%; 95 | background-color: ${theme.color.brand.primary}; 96 | color: ${theme.color.neutral.gray.b}; 97 | display: inline-block; 98 | padding: 0.25em 0.4em; 99 | font-weight: 500; 100 | line-height: 1; 101 | text-align: center; 102 | white-space: nowrap; 103 | vertical-align: baseline; 104 | border-radius: 0.25rem; 105 | } 106 | } 107 | footer { 108 | padding: ${theme.space.s}; 109 | margin-top: 12px; 110 | @media (max-width: 567px) { 111 | padding: ${theme.space.s}; 112 | } 113 | a { 114 | color: ${theme.color.brand.primary}; 115 | text-align: right; 116 | float: right; 117 | margin-right: ${theme.space.m}; 118 | } 119 | } 120 | } 121 | `}</style> 122 | </React.Fragment> 123 | ) 124 | } 125 | 126 | Project.propTypes = { 127 | project: PropTypes.object.isRequired, 128 | theme: PropTypes.object.isRequired, 129 | images: PropTypes.object.isRequired, 130 | } 131 | 132 | export default Project 133 | -------------------------------------------------------------------------------- /src/components/Project/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Project' 2 | -------------------------------------------------------------------------------- /src/components/Search/Hit.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | const Hit = (props) => { 6 | const { hit } = props 7 | 8 | return ( 9 | <React.Fragment> 10 | <Link to={hit.slug}>{hit.title}</Link> 11 | 12 | {/* --- STYLES --- */} 13 | <style jsx global>{` 14 | .ais-Hits-item { 15 | padding: 0.5em 0 0.5em 1em; 16 | position: relative; 17 | font-size: 1.2em; 18 | display: block; 19 | width: 100%; 20 | color: #666; 21 | } 22 | 23 | .ais-Hits-item:before { 24 | content: '•'; 25 | position: absolute; 26 | top: 0.5em; 27 | left: 0.1em; 28 | } 29 | `}</style> 30 | </React.Fragment> 31 | ) 32 | } 33 | 34 | Hit.propTypes = { 35 | hit: PropTypes.object.isRequired, 36 | } 37 | 38 | export default Hit 39 | -------------------------------------------------------------------------------- /src/components/Search/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { InstantSearch, SearchBox, Hits, Stats, Pagination } from 'react-instantsearch-dom' 4 | import algoliasearch from 'algoliasearch/lite' 5 | 6 | import Hit from './Hit' 7 | 8 | const Search = (props) => { 9 | const { algolia } = props 10 | const searchClient = algoliasearch(algolia.appId, algolia.searchOnlyApiKey) 11 | 12 | return ( 13 | <React.Fragment> 14 | <div className="search"> 15 | {algolia && algolia.appId && ( 16 | <InstantSearch searchClient={searchClient} indexName={algolia.indexName}> 17 | <SearchBox 18 | autoFocus 19 | translations={{ placeholder: 'Search' }} 20 | reset={<img src="" alt="" />} 21 | submit={<img src="" alt="" />} 22 | /> 23 | <Stats /> 24 | <Hits hitComponent={Hit} /> 25 | <Pagination /> 26 | </InstantSearch> 27 | )} 28 | </div> 29 | 30 | {/* --- STYLES --- */} 31 | <style jsx global>{` 32 | .ais-SearchBox { 33 | width: 100%; 34 | } 35 | .ais-SearchBox-form { 36 | position: relative; 37 | border-bottom: 1px solid #aaa; 38 | display: flex; 39 | justify-content: space-between; 40 | } 41 | .ais-SearchBox-input { 42 | border: none; 43 | padding: 0.2em; 44 | font-size: 1.4em; 45 | flex-grow: 1; 46 | } 47 | .ais-SearchBox-submit, 48 | .ais-SearchBox-reset { 49 | background: none; 50 | border: none; 51 | fill: #666; 52 | flex-grow: 0; 53 | } 54 | .ais-Stats { 55 | margin: 0.5em 0 2em 0.3em; 56 | font-size: 0.9em; 57 | color: #999; 58 | display: block; 59 | } 60 | .ais-Hits-list { 61 | list-style: none; 62 | padding: 0; 63 | } 64 | .ais-Pagination-list { 65 | display: flex; 66 | list-style: none; 67 | justify-content: center; 68 | padding: 0; 69 | } 70 | .ais-Pagination-item a, 71 | .ais-Pagination-item span { 72 | color: #666; 73 | font-size: 1.2em; 74 | display: block; 75 | padding: 0.5em 0.5em 2em; 76 | } 77 | .ais-Pagination-item a:hover { 78 | color: red; 79 | } 80 | .ais-Pagination-item.ais-Pagination-item--firstPage a, 81 | .ais-Pagination-item.ais-Pagination-item--previousPage a, 82 | .ais-Pagination-item.ais-Pagination-item--nextPage a { 83 | padding: 0.4em 0.5em 0.6em; 84 | } 85 | `}</style> 86 | </React.Fragment> 87 | ) 88 | } 89 | 90 | Search.propTypes = { 91 | algolia: PropTypes.object.isRequired, 92 | } 93 | 94 | export default Search 95 | -------------------------------------------------------------------------------- /src/components/Search/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Search' 2 | -------------------------------------------------------------------------------- /src/components/Seo/Seo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Helmet } from 'react-helmet' 4 | import config from '../../../content/meta/config' 5 | 6 | const Seo = (props) => { 7 | const { data } = props 8 | const postTitle = ((data || {}).frontmatter || {}).title 9 | const postDescription = ((data || {}).frontmatter || {}).description 10 | const postCover = ((data || {}).frontmatter || {}).cover 11 | const postSlug = ((data || {}).fields || {}).slug 12 | 13 | const title = postTitle ? `${postTitle}` : config.siteTitle 14 | const description = postDescription ? postDescription : config.siteDescription 15 | const image = postCover ? postCover : config.siteImage 16 | const url = config.siteUrl + config.pathPrefix + postSlug 17 | 18 | return ( 19 | <Helmet 20 | htmlAttributes={{ 21 | lang: config.siteLanguage, 22 | prefix: 'og: http://ogp.me/ns#', 23 | }} 24 | > 25 | {/* General tags */} 26 | <title>{title} 27 | 28 | {/* OpenGraph tags */} 29 | 30 | 31 | 32 | 33 | 34 | {/* Twitter Card tags */} 35 | 36 | 40 | 41 | ) 42 | } 43 | 44 | Seo.propTypes = { 45 | data: PropTypes.object, 46 | } 47 | 48 | export default Seo 49 | -------------------------------------------------------------------------------- /src/components/Seo/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Seo' 2 | -------------------------------------------------------------------------------- /src/components/Skill/Skill.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SkillCard from './SkillCard' 3 | 4 | const Skill = () => { 5 | return ( 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | {/* --- STYLES --- */} 17 | 27 |
28 | ) 29 | } 30 | 31 | export default Skill 32 | -------------------------------------------------------------------------------- /src/components/Skill/SkillCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Card, Progress } from 'antd' 3 | import PropTypes from 'prop-types' 4 | import 'antd/lib/card/style/index.css' 5 | import 'antd/lib/progress/style/index.css' 6 | 7 | const SkillCard = (props) => { 8 | const { item, percent } = props 9 | 10 | return ( 11 | 12 |
13 | 19 | ''} 30 | width={100} 31 | style={{ justifyContent: 'center' }} 32 | /> 33 | 34 |
35 |
36 | ) 37 | } 38 | 39 | SkillCard.propTypes = { 40 | item: PropTypes.string.isRequired, 41 | percent: PropTypes.string.isRequired, 42 | } 43 | 44 | export default SkillCard 45 | -------------------------------------------------------------------------------- /src/components/Skill/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Skill' 2 | -------------------------------------------------------------------------------- /src/components/Social/Social.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { FaGithub } from 'react-icons/fa/' 4 | import { FaLinkedin } from 'react-icons/fa/' 5 | import { FaTwitter } from 'react-icons/fa/' 6 | import { FaInstagram } from 'react-icons/fa/' 7 | import { FaDev } from 'react-icons/fa/' 8 | 9 | import config from '../../../content/meta/config' 10 | 11 | const Social = (props) => { 12 | const { theme } = props 13 | const items = config.authorSocialLinks 14 | const icons = { 15 | twitter: FaTwitter, 16 | github: FaGithub, 17 | linkedin: FaLinkedin, 18 | instagram: FaInstagram, 19 | dev: FaDev, 20 | } 21 | 22 | return ( 23 | 24 |
25 | {items.map((item) => { 26 | const Icon = icons[item.name] 27 | return ( 28 | 36 | 37 | 38 | ) 39 | })} 40 |
41 | 42 | {/* --- STYLES --- */} 43 | 85 |
86 | ) 87 | } 88 | 89 | Social.propTypes = { 90 | theme: PropTypes.object.isRequired, 91 | } 92 | 93 | export default Social 94 | -------------------------------------------------------------------------------- /src/components/Social/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Social' 2 | -------------------------------------------------------------------------------- /src/components/Subscribe/Subscribe.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import addToMailchimp from 'gatsby-plugin-mailchimp' 3 | import { Form, Input, Button, Divider } from 'antd' 4 | import { UserOutlined, MailOutlined } from '@ant-design/icons' 5 | import 'antd/dist/antd.css' 6 | 7 | export default class Subscribe extends React.Component { 8 | constructor() { 9 | super() 10 | this.state = { name: '', email: '', result: null } 11 | } 12 | 13 | handleSubmit = async (e) => { 14 | const result = await addToMailchimp(this.state.email, { FNAME: this.state.name }) 15 | if (result.result === 'error') { 16 | alert(`Whoops, ${this.state.name} you're already subscribed!`) 17 | } else { 18 | alert(`Thank you for subscribing ${this.state.name}!`) 19 | } 20 | this.setState({ result: result }) 21 | } 22 | 23 | handleEmailChange = (event) => { 24 | this.setState({ email: event.target.value }) 25 | } 26 | 27 | handleNameChange = (event) => { 28 | this.setState({ name: event.target.value }) 29 | } 30 | 31 | render() { 32 | const layout = { 33 | labelCol: { span: 5 }, 34 | wrapperCol: { span: 16 }, 35 | } 36 | 37 | const tailLayout = { 38 | wrapperCol: { offset: 5, span: 12 }, 39 | } 40 | 41 | return ( 42 | 43 | 44 |
45 |

46 | Like the article? Subscribe to get notified whenever a new article gets published! 47 |

48 |
49 | 54 | } onChange={this.handleEmailChange} /> 55 | 56 | 57 | 62 | } onChange={this.handleNameChange} /> 63 | 64 | 65 | 68 | 69 |
70 |
71 | 72 | {/* --- STYLES --- */} 73 | 84 |
85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/Subscribe/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Subscribe' 2 | -------------------------------------------------------------------------------- /src/components/Work/Education.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Education = (props) => { 5 | const { theme, school, degree, gpa, honors } = props 6 | 7 | return ( 8 | 9 |
10 |
11 |
{school}
12 |
{degree}
13 |
14 |
15 |
GPA: {gpa}
16 | {honors &&
{honors}
} 17 |
18 |
19 | {/* --- STYLES --- */} 20 | 50 |
51 | ) 52 | } 53 | 54 | Education.propTypes = { 55 | theme: PropTypes.object.isRequired, 56 | school: PropTypes.string.isRequired, 57 | degree: PropTypes.string.isRequired, 58 | gpa: PropTypes.string.isRequired, 59 | honors: PropTypes.string, 60 | } 61 | 62 | export default Education 63 | -------------------------------------------------------------------------------- /src/components/Work/Volunteer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const Volunteer = (props) => { 5 | const { theme, organization, duration, role } = props 6 | 7 | return ( 8 | 9 |
10 |
11 |
{organization}
12 |
{duration}
13 |
14 |
{role}
15 |
16 | 17 | {/* --- STYLES --- */} 18 | 49 |
50 | ) 51 | } 52 | 53 | Volunteer.propTypes = { 54 | theme: PropTypes.object.isRequired, 55 | organization: PropTypes.string.isRequired, 56 | role: PropTypes.string.isRequired, 57 | duration: PropTypes.string.isRequired, 58 | } 59 | 60 | export default Volunteer 61 | -------------------------------------------------------------------------------- /src/components/Work/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Work' 2 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/education.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Education Component renders correctly with honors 1`] = ` 4 | Array [ 5 |
8 |
11 |
14 | Lakeland University 15 |
16 |
19 | B.S. Computer Science 20 |
21 |
22 |
25 |
26 | GPA: 27 | 3.9375 28 |
29 |
30 | Summa cum Laude 31 |
32 |
33 |
, 34 | , 68 | ] 69 | `; 70 | 71 | exports[`Education Component renders correctly without honors 1`] = ` 72 | Array [ 73 |
76 |
79 |
82 | Milwaukee Area Technical College 83 |
84 |
87 | A.S. Mobile Application Development 88 |
89 |
90 |
93 |
94 | GPA: 95 | 3.729 96 |
97 |
98 |
, 99 | , 133 | ] 134 | `; 135 | -------------------------------------------------------------------------------- /src/components/__tests__/__snapshots__/volunteer.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Volunteer Component renders correctly 1`] = ` 4 | Array [ 5 |
8 |
11 |
14 | Milwaukee Area Technical College 15 |
16 |
19 | Jan 2015 - Present 20 |
21 |
22 |
25 | ITDEV Advisory Board Member 26 |
27 |
, 28 | , 62 | ] 63 | `; 64 | -------------------------------------------------------------------------------- /src/components/__tests__/article.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { create } from 'react-test-renderer' 3 | import Article from '../Article' 4 | import Headline from '../Article/Headline' 5 | import SubHeadline from '../Article/SubHeadline' 6 | import Bodytext from '../Article/Bodytext' 7 | const theme = require('../../theme/theme.json') 8 | 9 | describe('Article Component', () => { 10 | it('renders correctly with title', () => { 11 | const tree = create( 12 |
13 | 14 | 15 |
16 | ).toJSON() 17 | expect(tree).toMatchSnapshot() 18 | }) 19 | it('renders correctly without title', () => { 20 | const tree = create( 21 |
22 | 23 | 24 | 25 |
26 | ).toJSON() 27 | expect(tree).toMatchSnapshot() 28 | }) 29 | }) 30 | 31 | const mock_page = 32 | 'function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n\nfunction _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n\nfunction _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n\n/* @jsx mdx */\nvar _frontmatter = {\n "title": "Test"\n};\n\nvar makeShortcode = function makeShortcode(name) {\n return function MDXDefaultShortcode(props) {\n console.warn("Component " + name + " was not imported, exported, or provided by MDXProvider as global scope");\n return mdx("div", props);\n };\n};\n\nvar layoutProps = {\n _frontmatter: _frontmatter\n};\nvar MDXLayout = "wrapper";\nreturn function MDXContent(_ref) {\n var components = _ref.components,\n props = _objectWithoutProperties(_ref, ["components"]);\n\n return mdx(MDXLayout, _extends({}, layoutProps, props, {\n components: components,\n mdxType: "MDXLayout"\n }), mdx("p", null, "test"));\n}\n;\nMDXContent.isMDXComponent = true;' 33 | -------------------------------------------------------------------------------- /src/components/__tests__/education.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { create } from 'react-test-renderer' 3 | import Education from '../Work/Education' 4 | const theme = require('../../theme/theme.json') 5 | 6 | describe('Education Component', () => { 7 | it('renders correctly without honors', () => { 8 | const tree = create( 9 | 15 | ).toJSON() 16 | expect(tree).toMatchSnapshot() 17 | }) 18 | it('renders correctly with honors', () => { 19 | const tree = create( 20 | 27 | ).toJSON() 28 | expect(tree).toMatchSnapshot() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/__tests__/footer.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { create } from 'react-test-renderer' 3 | import Footer from '../Footer' 4 | const theme = require('../../theme/theme.json') 5 | 6 | describe('Footer Component', () => { 7 | it('renders correctly', () => { 8 | const tree = create(