├── __tests
├── config
│ ├── fileMock.js
│ ├── setupTests.js
│ └── componentsMock.js
└── src
│ ├── routes
│ └── index.test.js
│ ├── screens
│ └── Home
│ │ └── Home.test.js
│ ├── components
│ ├── Home
│ │ ├── Path.test.js
│ │ └── HelloWorld.test.js
│ └── @shared
│ │ ├── Footer.test.js
│ │ └── Header.test.js
│ └── layouts
│ └── App.test.js
├── .npmignore
├── __assets
├── Icon.jpg
├── Gallery1.jpg
├── Gallery2.jpg
├── Gallery3.jpg
├── Gallery4.jpg
├── PromoMarquee.jpg
├── ScreenshotTrackerPromo.gif
├── ScreenshotTrackerPromo.mp4
├── ScreenshotTrackerPromo.prd
└── for_github_readme
│ ├── Gallery1.jpg
│ ├── Gallery2.jpg
│ ├── Gallery3.jpg
│ ├── Gallery4.jpg
│ └── PromoMarquee.jpg
├── public
├── appicon_256.png
├── appicon_512.png
└── index.html
├── sentry.js
├── src
├── constants.js
├── .nomad-codecheckrc.js
├── store.js
├── reducers.js
├── styles
│ ├── ant.vars.scss
│ └── app.global.scss
├── routes.js
├── electron_listeners.js
├── index.js
├── actions.js
├── screens
│ ├── home.js
│ ├── run_list.js
│ ├── about.js
│ ├── new_run.js
│ └── run_result.js
└── layout.js
├── .editorconfig
├── postcss.config.js
├── .babelrc
├── .eslintrc.js
├── scripts
└── notarize.js
├── .gitignore
├── LICENSE
├── storage.js
├── README.md
├── webpack.config.babel.js
├── package.json
└── main.js
/__tests/config/fileMock.js:
--------------------------------------------------------------------------------
1 | export default 'file'
2 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | _ignore/
2 | docs/
3 | release/
4 | dist/
5 | .editorconfig
6 | __snapshots__
7 |
8 |
--------------------------------------------------------------------------------
/__assets/Icon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/Icon.jpg
--------------------------------------------------------------------------------
/__assets/Gallery1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/Gallery1.jpg
--------------------------------------------------------------------------------
/__assets/Gallery2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/Gallery2.jpg
--------------------------------------------------------------------------------
/__assets/Gallery3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/Gallery3.jpg
--------------------------------------------------------------------------------
/__assets/Gallery4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/Gallery4.jpg
--------------------------------------------------------------------------------
/public/appicon_256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/public/appicon_256.png
--------------------------------------------------------------------------------
/public/appicon_512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/public/appicon_512.png
--------------------------------------------------------------------------------
/__assets/PromoMarquee.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/PromoMarquee.jpg
--------------------------------------------------------------------------------
/__assets/ScreenshotTrackerPromo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/ScreenshotTrackerPromo.gif
--------------------------------------------------------------------------------
/__assets/ScreenshotTrackerPromo.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/ScreenshotTrackerPromo.mp4
--------------------------------------------------------------------------------
/__assets/ScreenshotTrackerPromo.prd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/ScreenshotTrackerPromo.prd
--------------------------------------------------------------------------------
/sentry.js:
--------------------------------------------------------------------------------
1 | const Sentry = require('@sentry/electron')
2 |
3 | Sentry.init({ dsn: 'https://09f1b01e6e5d40d5a6e5a8135cc5cd55@sentry.io/4867322' })
4 |
--------------------------------------------------------------------------------
/__assets/for_github_readme/Gallery1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/for_github_readme/Gallery1.jpg
--------------------------------------------------------------------------------
/__assets/for_github_readme/Gallery2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/for_github_readme/Gallery2.jpg
--------------------------------------------------------------------------------
/__assets/for_github_readme/Gallery3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/for_github_readme/Gallery3.jpg
--------------------------------------------------------------------------------
/__assets/for_github_readme/Gallery4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/for_github_readme/Gallery4.jpg
--------------------------------------------------------------------------------
/__assets/for_github_readme/PromoMarquee.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nomadinteractive/screenshot-tracker/HEAD/__assets/for_github_readme/PromoMarquee.jpg
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 |
2 | export const LIST_RUNS_SUCCESS = 'LIST_RUNS_SUCCESS'
3 | export const NEW_RUN_SUCCESS = 'NEW_RUN_SUCCESS'
4 |
5 | export default {}
6 |
--------------------------------------------------------------------------------
/__tests/config/setupTests.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import { configure } from "enzyme";
3 | import Adapter from "enzyme-adapter-react-16";
4 | // Adapter
5 | configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = crlf
8 | indent_size = 2
9 | indent_style = space
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 |
13 | [*.md]
14 | trim_trailing_whitespace = false
15 |
--------------------------------------------------------------------------------
/src/.nomad-codecheckrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "rules": [
3 | // "branch-name-lint",
4 | // "no-assets-outside-assets-folder",
5 | // "no-network-request-outside-network-managers-folder",
6 | // "no-storage-outside-persistent-data-managers-folder",
7 | ]
8 | }
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Screenshot Tracker
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux'
2 | import thunk from 'redux-thunk'
3 |
4 | import rootReducer from './reducers'
5 |
6 | const storeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // eslint-disable-line
7 |
8 | export default createStore(
9 | rootReducer,
10 | storeEnhancers(applyMiddleware(thunk))
11 | )
12 |
--------------------------------------------------------------------------------
/__tests/src/routes/index.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 |
6 | // Module
7 | import Root from "@/routes/Root";
8 |
9 | describe("Routes", () => {
10 | it('should render correctly in "debug" mode', () => {
11 | const component = shallow( );
12 | expect(toJson(component)).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/reducers.js:
--------------------------------------------------------------------------------
1 | import {
2 | LIST_RUNS_SUCCESS,
3 | } from './constants'
4 |
5 | const initialState = {
6 | runs: [],
7 | }
8 |
9 | export default function (state = initialState, action) {
10 | // console.log('---> Reducer Update ', action)
11 | switch (action.type) {
12 | case LIST_RUNS_SUCCESS:
13 | return {
14 | ...state,
15 | runs: action.runs
16 | }
17 | default:
18 | return state
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/__tests/src/screens/Home/Home.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 | // Module
6 | import Home from "@/screens/Home/Home.js";
7 |
8 | describe("Screen Home", () => {
9 | it('should render correctly in "debug" mode', () => {
10 | const component = shallow( );
11 | expect(toJson(component)).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests/src/components/Home/Path.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 | // Module
6 | import Path from "@/components/Home/Path";
7 |
8 | describe("Component Path", () => {
9 | it('should render correctly in "debug" mode', () => {
10 | const component = shallow( );
11 | expect(toJson(component)).toMatchSnapshot();
12 |
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/__tests/config/componentsMock.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import PropTypes from "prop-types";
4 |
5 | module.exports = new Proxy(
6 | {},
7 | {
8 | get: (target, property) => {
9 | const Mock = ({ children }) => {children} ;
10 |
11 | Mock.displayName = property;
12 | Mock.propTypes = {
13 | children: PropTypes.any
14 | };
15 |
16 | return Mock;
17 | }
18 | }
19 | );
20 |
--------------------------------------------------------------------------------
/__tests/src/components/@shared/Footer.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 | // Module
6 | import Footer from "@/components/@shared/Footer";
7 |
8 | describe("Component Footer", () => {
9 | it('should render correctly in "debug" mode', () => {
10 | const component = shallow();
11 | expect(toJson(component)).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests/src/components/@shared/Header.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 | // Module
6 | import Header from "@/components/@shared/Header";
7 |
8 | describe("Component Header", () => {
9 | it('should render correctly in "debug" mode', () => {
10 | const component = shallow();
11 | expect(toJson(component)).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests/src/components/Home/HelloWorld.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 | // Module
6 | import HelloWorld from "@/components/Home/HelloWorld";
7 |
8 | describe("Component HelloWorld", () => {
9 | it('should render correctly in "debug" mode', () => {
10 | const component = shallow( );
11 | expect(toJson(component)).toMatchSnapshot();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/__tests/src/layouts/App.test.js:
--------------------------------------------------------------------------------
1 | // Libs
2 | import React from "react";
3 | import { shallow } from "enzyme";
4 | import toJson from "enzyme-to-json";
5 |
6 | // Module
7 | import LayoutApp from "@/layouts/App";
8 |
9 | describe("Layout LayoutApp", () => {
10 | it('should render correctly in "debug" mode', () => {
11 | const component = shallow(
12 |
13 | children
14 |
15 | );
16 | expect(toJson(component)).toMatchSnapshot();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | "postcss-cssnext": {
4 | browsers: [
5 | "Firefox >= 58",
6 | "Chrome >= 62",
7 | "ie >= 10",
8 | "last 4 versions",
9 | "Safari >= 9"
10 | ]
11 | },
12 | "postcss-import": {},
13 | "postcss-pxtorem": {
14 | rootValue: 16,
15 | unitPrecision: 5,
16 | propList: ["*"],
17 | selectorBlackList: ["html", "body"],
18 | replace: true,
19 | mediaQuery: false,
20 | minPixelValue: 0
21 | },
22 | "postcss-nested": {}
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/src/styles/ant.vars.scss:
--------------------------------------------------------------------------------
1 | $primary-color: #1890ff; // primary color for all components
2 | $link-color: #1890ff; // link color
3 | $success-color: #52c41a; // success state color
4 | $warning-color: #faad14; // warning state color
5 | $error-color: #f5222d; // error state color
6 | $font-size-base: 14px; // major text font size
7 | $heading-color: rgba(0, 0, 0, 0.85); // heading text color
8 | $text-color: rgba(0, 0, 0, 0.65); // major text color
9 | $text-color-secondary: rgba(0, 0, 0, 0.45); // secondary text color
10 | $disabled-color: rgba(0, 0, 0, 0.25); // disable state color
11 | $border-radius-base: 5px; // major border radius
12 | $border-color-base: #d9d9d9; // major border color
13 | $box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15); // major shadow for layers
14 |
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { HashRouter, Switch, Route } from 'react-router-dom'
3 | import { hot } from 'react-hot-loader/root'
4 |
5 | import Home from './screens/home'
6 | import NewRun from './screens/new_run'
7 | import RunList from './screens/run_list'
8 | import RunResult from './screens/run_result'
9 | import About from './screens/about'
10 |
11 | const Routes = () => (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | )
22 |
23 | export default hot(Routes)
24 |
--------------------------------------------------------------------------------
/src/electron_listeners.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { connect } from 'react-redux'
3 | import PropTypes from 'prop-types'
4 |
5 | import { updateRun } from './actions'
6 |
7 | const electron = window.require('electron')
8 | const ipc = electron.ipcRenderer
9 |
10 | const ElectronListeners = (props) => {
11 | const { updateRunAction } = props
12 | ipc.on('run-updated', (event, arg) => {
13 | updateRunAction(arg.id, arg)
14 | })
15 | return ( )
16 | }
17 |
18 | ElectronListeners.propTypes = {
19 | updateRunAction: PropTypes.func.isRequired,
20 | }
21 |
22 | const mapStateToProps = () => ({ })
23 |
24 | const mapDispatchToProps = (dispatch) => ({
25 | updateRunAction: updateRun(dispatch)
26 | })
27 |
28 | export default connect(mapStateToProps, mapDispatchToProps)(ElectronListeners)
29 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { render } from 'react-dom'
3 | import { Provider } from 'react-redux'
4 | import Darkmode from 'darkmode-js'
5 |
6 | import reduxStore from './store'
7 | import Routes from './routes'
8 | import ElectronListeners from './electron_listeners'
9 |
10 | import 'react-image-lightbox/style.css'
11 | import './styles/app.global.scss'
12 |
13 | new Darkmode({
14 | label: '🌓'
15 | }).showWidget()
16 |
17 | const App = () => (
18 |
19 |
20 |
21 |
22 | )
23 |
24 | render( , document.getElementById('app'))
25 |
26 | if (module.hot) {
27 | module.hot.accept('./routes', () => {
28 | require('./routes') // eslint-disable-line
29 | render( , document.getElementById('app'))
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/env", "@babel/react"],
3 | "plugins": [
4 | [
5 | "import",
6 | {
7 | "libraryName": "antd",
8 | "style": true
9 | },
10 | "ant"
11 | ],
12 | "@babel/plugin-proposal-class-properties",
13 | "@babel/transform-runtime",
14 | "react-hot-loader/babel",
15 | [
16 | "babel-plugin-root-import",
17 | {
18 | "rootPathPrefix": "@",
19 | "rootPathSuffix": "./src"
20 | }
21 | ]
22 | ],
23 | "env": {
24 | "development": {
25 | "plugins": ["@babel/plugin-transform-modules-commonjs"]
26 | },
27 | "test": {
28 | "plugins": ["@babel/plugin-transform-modules-commonjs"]
29 | },
30 | "production": {
31 | "plugins": ["transform-react-remove-prop-types"]
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "plugins": ["react"],
3 | "parser": "babel-eslint",
4 | "parserOptions": {
5 | "ecmaVersion": 6,
6 | "sourceType": "module",
7 | "ecmaFeatures": {
8 | "jsx": true
9 | }
10 | },
11 | "env": {
12 | "es6": true,
13 | "browser": true,
14 | "node": true,
15 | "mocha": true
16 | },
17 | "extends": [
18 | "@nomadinteractive",
19 | // "eslint:recommended",
20 | // "plugin:react/recommended"
21 | ],
22 | // "rules": {
23 | // "no-console": "off",
24 | // "no-undef": "warn",
25 | // "no-undefined": "warn",
26 | // "no-unused-vars": "warn",
27 | // "no-extra-parens": ["error", "all", { "ignoreJSX": "all" }],
28 | // "no-constant-condition": "warn",
29 | // "react/prop-types": "warn"
30 | // },
31 | "settings": {
32 | "react": {
33 | "version": "16"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config() // eslint-disable-line
2 | const { notarize } = require('electron-notarize') // eslint-disable-line
3 |
4 | exports.default = async function notarizing(context) {
5 | console.log('Notirizing the signed app...')
6 |
7 | const { electronPlatformName, appOutDir } = context
8 |
9 | if (process.env.CSC_IDENTITY_AUTO_DISCOVERY === 'false') return
10 | if (electronPlatformName !== 'darwin') return
11 |
12 | const appName = context.packager.appInfo.productFilename
13 |
14 | const appleId = process.env.NOMAD_APPLEID_EMAIL
15 | const appleIdPassword = process.env.NOMAD_APPLEID_PASS
16 | const ascProvider = process.env.NOMAD_TEAM_SHORTNAME
17 |
18 | const opts = {
19 | appBundleId: 'co.nomadinteractive.screenshot-tracker',
20 | appPath: `${appOutDir}/${appName}.app`,
21 | appleId,
22 | appleIdPassword,
23 | ascProvider,
24 | }
25 | // console.log('--> Notarize opts', opts)
26 | // eslint-disable-next-line
27 | return await notarize(opts)
28 | }
29 |
--------------------------------------------------------------------------------
/src/actions.js:
--------------------------------------------------------------------------------
1 | import {
2 | LIST_RUNS_SUCCESS,
3 | } from './constants'
4 |
5 | const electron = window.require('electron')
6 | const storage = electron.remote.require('./storage')
7 | const ipc = electron.ipcRenderer
8 |
9 | export const listRuns = (dispatch) => () => {
10 | const runs = storage.listRuns()
11 | const runsRaw = runs ? JSON.parse(JSON.stringify(runs)) : []
12 | // console.log('--> listRuns - runsRaw', runsRaw)
13 | dispatch({
14 | type: LIST_RUNS_SUCCESS,
15 | runs: runsRaw
16 | })
17 | return runsRaw
18 | }
19 |
20 | export const saveRun = (dispatch) => (runObj) => {
21 | const savedRun = storage.saveRun(runObj)
22 | listRuns(dispatch)()
23 | ipc.send('take-screenshots-of-a-run', { id: savedRun.id })
24 | return savedRun.id
25 | }
26 |
27 | export const updateRun = (dispatch) => (id, updatedRunObj) => {
28 | // console.log('--> id, updatedRunObj', id, updatedRunObj)
29 | storage.updateRun(id, updatedRunObj)
30 | listRuns(dispatch)()
31 | }
32 |
33 | export default {}
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 |
3 | # Build folder and files #
4 | ##########################
5 | release/
6 |
7 | # Development folders and files #
8 | #################################
9 | .tmp/
10 | dist/
11 | node_modules/
12 | docs/
13 | __snapshots__
14 | *.compiled.*
15 |
16 | # Folder config file #
17 | ######################
18 | Desktop.ini
19 |
20 | # Folder notes #
21 | ################
22 | _ignore/
23 |
24 | # Log files & folders #
25 | #######################
26 | logs/
27 | *.log
28 | npm-debug.log*
29 | .npm
30 |
31 | # Packages #
32 | ############
33 | # it's better to unpack these files and commit the raw source
34 | # git has its own built in compression methods
35 | *.7z
36 | *.dmg
37 | *.gz
38 | *.iso
39 | *.jar
40 | *.rar
41 | *.tar
42 | *.zip
43 |
44 | # Photoshop & Illustrator files #
45 | #################################
46 | *.ai
47 | *.eps
48 | *.psd
49 |
50 | # Windows & Mac file caches #
51 | #############################
52 | .DS_Store
53 | Thumbs.db
54 | ehthumbs.db
55 |
56 | # Windows shortcuts #
57 | #####################
58 | *.lnk
59 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Nomad Interactive
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.
--------------------------------------------------------------------------------
/storage.js:
--------------------------------------------------------------------------------
1 | const Store = require('electron-store')
2 |
3 | const store = new Store()
4 |
5 | const KEYS = {
6 | RUNS: 'runs',
7 | RUNS_NEXT_ID: 'runs_next_id',
8 | LAST_RUN_OBJ: 'last_run_obj',
9 | }
10 |
11 | const getNextRunId = () => (parseInt(store.get(KEYS.RUNS_NEXT_ID), 10) || 1)
12 |
13 | const saveRun = (runData) => {
14 | const runs = store.get(KEYS.RUNS) || []
15 | const newRunId = getNextRunId()
16 | const newRunObj = {
17 | id: newRunId,
18 | progress: 0,
19 | ...runData
20 | }
21 | runs.push(newRunObj)
22 | store.set(KEYS.RUNS, runs)
23 | store.set(KEYS.RUNS_NEXT_ID, newRunId + 1)
24 | return newRunObj
25 | }
26 |
27 | const listRuns = () => store.get(KEYS.RUNS)
28 |
29 | const getRun = (runId) => {
30 | const runs = listRuns()
31 | let foundRun = null
32 | runs.forEach((run) => {
33 | if (parseInt(run.id, 10) === parseInt(runId, 10)) {
34 | foundRun = run
35 | }
36 | })
37 | return foundRun
38 | }
39 |
40 | const updateRun = (runId, updatedData) => {
41 | const runs = listRuns()
42 | const updatedRuns = []
43 | runs.forEach((run) => {
44 | updatedRuns.push({
45 | ...run,
46 | ...(parseInt(run.id, 10) === parseInt(runId, 10) ? updatedData : {})
47 | })
48 | })
49 | store.set(KEYS.RUNS, updatedRuns)
50 | return true
51 | }
52 |
53 | const deleteRun = (runId) => {
54 | const runs = listRuns()
55 | const updatedRuns = []
56 | runs.forEach((run) => {
57 | if (parseInt(run.id, 10) !== parseInt(runId, 10)) {
58 | updatedRuns.push(run)
59 | }
60 | })
61 | store.set(KEYS.RUNS, updatedRuns)
62 | return true
63 | }
64 |
65 | const clearRuns = () => {
66 | store.set(KEYS.RUNS, null)
67 | store.set(KEYS.RUNS_NEXT_ID, null)
68 | }
69 |
70 | const getLastRunObj = () => store.get(KEYS.LAST_RUN_OBJ)
71 | const saveLastRunObj = (runData) => store.set(KEYS.LAST_RUN_OBJ, runData)
72 |
73 | module.exports = {
74 | getNextRunId,
75 | saveRun,
76 | listRuns,
77 | getRun,
78 | updateRun,
79 | deleteRun,
80 | clearRuns,
81 | getLastRunObj,
82 | saveLastRunObj
83 | }
84 |
--------------------------------------------------------------------------------
/src/styles/app.global.scss:
--------------------------------------------------------------------------------
1 | @import "./ant.vars.scss";
2 |
3 | body {
4 | background-color: #ffffff;
5 | }
6 |
7 | .ant-menu-inline-collapsed {
8 | width: 50px;
9 | }
10 |
11 | .ant-menu-inline-collapsed > .ant-menu-item,
12 | .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title {
13 | padding: 0 18px !important;
14 | }
15 |
16 | .ant-layout-sider {
17 | .ant-layout-sider-trigger {
18 | border-top: 1px solid #efefef;
19 | }
20 | }
21 |
22 | .darkmode-layer, .darkmode-toggle {
23 | z-index: 10000;
24 | }
25 |
26 | .darkmode--activated {
27 | // ignore dark mode for elements
28 | img.screenshot,
29 | img.screenshot-tarcker-icon {
30 | mix-blend-mode: difference;
31 | }
32 |
33 | // force dark mode
34 | .ant-btn.ant-btn-default,
35 | .ant-layout-sider .anticon,
36 | .ant-menu-submenu-popup .ant-menu-sub {
37 | mix-blend-mode: normal;
38 | }
39 |
40 | .ant-layout-sider,
41 | .ant-layout-sider-trigger,
42 | .ant-layout-sider .ant-menu,
43 | .ant-menu-submenu-popup .ant-menu-sub {
44 | background-color: #eeeeee;
45 | }
46 |
47 | .ReactModalPortal {
48 | position: absolute;
49 | z-index: 100000000;
50 | }
51 | }
52 |
53 | .screenshotContainer {
54 | display: inline-block;
55 | max-height: 150px;
56 | overflow: hidden;
57 | box-shadow: rgba(0,0,0,0.2) 0 1px 2px;
58 | transition: all 0.3s ease-in-out;
59 |
60 | &:hover {
61 | box-shadow: rgba(0,0,0,0.2) 2px 10px 20px;
62 | }
63 | }
64 |
65 | .screenshot {
66 | max-width: 100px;
67 | }
68 |
69 | .aboutPage {
70 | main.ant-layout-content {
71 | text-align: center;
72 | }
73 |
74 | .appicon {
75 | padding: 50px;
76 | }
77 |
78 | .credits {
79 | margin-bottom: 50px;
80 |
81 | .ant-col.wrapper-col {
82 | text-align: left;
83 | margin-top: 30px;
84 | padding-top: 30px;
85 | border-top: 1px solid #efefef;
86 | }
87 | }
88 | }
89 |
90 | .pageWithGradientBg main.ant-layout-content {
91 | background: linear-gradient(0deg, rgba(255,255,255,1) 30%, rgba(215,243,255,1) 100%) !important;
92 | }
93 |
94 | .support-bar {
95 | max-width: 700px;
96 | margin: 0 auto;
97 | border-top: 1px solid #efefef;
98 | padding: 40px;
99 | text-align: center;
100 | font-size: 0.9em;
101 | }
102 |
--------------------------------------------------------------------------------
/src/screens/home.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Link } from 'react-router-dom'
3 | import {
4 | Result,
5 | Button,
6 | Typography,
7 | Tag,
8 | // Icon
9 | } from 'antd'
10 | import Layout from '../layout'
11 |
12 | const { Title } = Typography
13 |
14 | const { shell } = window.require('electron')
15 |
16 | class Home extends Component {
17 | componentDidMount() {
18 | document.addEventListener('click', (e) => {
19 | if (
20 | e.target.tagName === 'A'
21 | && e.target.className.startsWith('ext')
22 | && e.target.href.startsWith('http')
23 | ) {
24 | e.preventDefault()
25 | shell.openExternal(e.target.href)
26 | }
27 | })
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
42 | )}
43 | title={(
44 |
45 | Screenshot Tracker
46 | v1.0
47 |
48 | )}
49 | subTitle={(
50 |
51 |
Let's get started with a new run.
52 | A “run” is a session of screenshots taken from a list of
53 | web page urls you provide.
54 |
55 | You can create multiple runs and repeat them as many times as you want.
56 |
57 | )}
58 | extra={(
59 |
60 |
65 | New Run
66 |
67 |
68 | )}
69 | />
70 |
85 |
86 | )
87 | }
88 | }
89 |
90 | export default Home
91 |
--------------------------------------------------------------------------------
/src/screens/run_list.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 | import PropTypes from 'prop-types'
5 | import {
6 | Table,
7 | Modal,
8 | Icon,
9 | Button
10 | } from 'antd'
11 |
12 | import { listRuns } from '../actions'
13 | import Layout from '../layout'
14 |
15 | // const path = window.require('path')
16 | const { remote } = window.require('electron')
17 | const storage = remote.require('./storage')
18 |
19 | const loading =
20 |
21 | class RunList extends Component {
22 | componentDidMount() {
23 | const { listRunsAction } = this.props
24 | listRunsAction()
25 | }
26 |
27 | deleteRun(runId) {
28 | const { listRunsAction } = this.props
29 |
30 | Modal.confirm({
31 | content: 'Are you sure you want to delete this run?',
32 | okText: 'Delete',
33 | okType: 'danger',
34 | onOk: () => {
35 | storage.deleteRun(runId)
36 | listRunsAction()
37 | }
38 | })
39 | }
40 |
41 | render() {
42 | const { runs } = this.props
43 | // console.log('--> runs', runs)
44 |
45 | return (
46 |
47 |
48 | {runs ? (
49 | (
56 |
57 | {text}
58 |
59 | )
60 | },
61 | {
62 | title: 'Run',
63 | dataIndex: 'name',
64 | key: 'name',
65 | render: (text, record) => (
66 |
67 | {text}
68 |
69 | )
70 | },
71 | {
72 | title: 'Pages',
73 | dataIndex: 'pages',
74 | key: 'pages',
75 | render: (text, record) => record.pages.length
76 | },
77 | {
78 | title: 'Actions',
79 | key: 'actions',
80 | render: (text, record) => (
81 |
82 | {
85 | this.deleteRun(record.id)
86 | }}
87 | >
88 |
89 |
90 |
91 | )
92 | },
93 | ]}
94 | dataSource={runs}
95 | rowKey="id"
96 | pagination={false}
97 | // scroll={{ y: '100vh' }}
98 | scroll={{ y: 'max-content' }}
99 | />
100 | ) : (
101 | {loading}
102 | )}
103 |
104 |
105 | )
106 | }
107 | }
108 |
109 | RunList.propTypes = {
110 | listRunsAction: PropTypes.func.isRequired,
111 | runs: PropTypes.array.isRequired // eslint-disable-line
112 | }
113 |
114 | const mapStateToProps = (state) => ({
115 | runs: state.runs
116 | })
117 |
118 | const mapDispatchToProps = (dispatch) => ({
119 | listRunsAction: listRuns(dispatch)
120 | })
121 |
122 | export default connect(mapStateToProps, mapDispatchToProps)(RunList)
123 |
--------------------------------------------------------------------------------
/src/layout.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Link } from 'react-router-dom'
3 | import { connect } from 'react-redux'
4 | import PropTypes from 'prop-types'
5 | import { Layout, Menu, Icon } from 'antd'
6 |
7 | const { Sider, Content } = Layout
8 |
9 | class AppLayout extends Component {
10 | constructor(props) {
11 | super(props)
12 | this.state = {
13 | collapsed: true,
14 | }
15 | }
16 |
17 | toggle = () => {
18 | const { collapsed } = this.state
19 | this.setState({
20 | collapsed: !collapsed,
21 | })
22 | }
23 |
24 | render() {
25 | const {
26 | children,
27 | style,
28 | className
29 | } = this.props
30 | const { collapsed } = this.state
31 |
32 | return (
33 |
37 |
40 |
41 | {collapsed ? '' : ' Hide'}
42 |
43 | )}
44 | collapsible
45 | collapsed={collapsed}
46 | onCollapse={this.toggle}
47 | theme="light"
48 | collapsedWidth={50}
49 | style={{
50 | paddingTop: 20,
51 | boxShadow: '0 2px 8px rgba(0,0,0,0.11)',
52 | zIndex: 3
53 | }}
54 | >
55 |
62 |
63 |
64 |
65 | New Run
66 |
67 |
68 |
69 |
70 |
71 | Runs
72 |
73 |
74 |
75 |
84 |
85 |
86 |
87 | About
88 |
89 |
90 |
91 |
92 |
93 |
101 | {children}
102 |
103 |
104 |
105 | )
106 | }
107 | }
108 |
109 | AppLayout.defaultProps = {
110 | className: '',
111 | style: {}
112 | }
113 |
114 | AppLayout.propTypes = {
115 | runs: PropTypes.array.isRequired, // eslint-disable-line
116 | children: PropTypes.node.isRequired,
117 | className: PropTypes.string,
118 | style: PropTypes.object // eslint-disable-line react/forbid-prop-types
119 | }
120 |
121 | const mapStateToProps = (state) => ({
122 | runs: state.runs
123 | })
124 |
125 | const mapDispatchToProps = () => ({})
126 | // const mapDispatchToProps = (dispatch) => ({})
127 |
128 | export default connect(mapStateToProps, mapDispatchToProps)(AppLayout)
129 |
--------------------------------------------------------------------------------
/src/screens/about.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 |
3 | import React, { Component } from 'react'
4 | import {
5 | Row,
6 | Col,
7 | Typography,
8 | Tag,
9 | } from 'antd'
10 |
11 | import Layout from '../layout'
12 |
13 | const { Title } = Typography
14 |
15 | const { shell } = window.require('electron')
16 |
17 | class About extends Component {
18 | componentDidMount() {
19 | document.addEventListener('click', (e) => {
20 | if (
21 | e.target.tagName === 'A'
22 | && e.target.className.startsWith('ext')
23 | && e.target.href.startsWith('http')
24 | ) {
25 | e.preventDefault()
26 | shell.openExternal(e.target.href)
27 | }
28 | })
29 | }
30 |
31 | render() {
32 | return (
33 |
34 |
35 |
42 |
43 |
44 | Screenshot Tracker
45 | v1.0
46 |
47 |
48 | by
49 | {' '}
50 | Nomad Interactive
51 | , 2020
52 |
53 |
54 |
55 |
56 | Credits
57 |
58 | We used many open source tools in this tools build. The honorable mentions below. See
59 | {' '}
60 |
61 | package.json in Github
62 |
63 | {' '}
64 | for full list of open source packages used.
65 |
66 |
67 |
68 |
77 |
78 |
79 |
87 |
88 |
89 |
90 |
91 |
92 | )
93 | }
94 | }
95 |
96 | export default About
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Screenshot Tracker Desktop App
2 |
3 | 
4 |
5 | A simple screenshot tarcking tool based on electron's embedded chromium and pupeteer.
6 |
7 |
8 | ## Screenshots
9 |
10 | [](./__assets/for_github_readme/Gallery1.jpg)
11 |
12 | [](./__assets/for_github_readme/Gallery2.jpg)
13 |
14 | [](./__assets/for_github_readme/Gallery3.jpg)
15 |
16 | [](./__assets/for_github_readme/Gallery4.jpg)
17 |
18 |
19 | ### Features / Tech Used
20 |
21 | - Electron
22 | - Webpack 8
23 | - Babel 7
24 | - React 16
25 | - ES6
26 | - PostCSS
27 | - Sass (Injection by modules and in the traditional way)
28 | - Ant Design (Global theme based on the Less Ant variables)
29 | - Jest
30 | - Enzyme
31 | - Eslint
32 | - Hot realod
33 | - Friendly architecture
34 | - Export for Mac, Linux, Windows
35 |
36 |
37 | ### Known issues
38 |
39 | Looking for support for the following known issues:
40 |
41 | - Ability to capture cookie/session and use in screenshot sessions
42 | - True mobile device emulation (right now just the resolution/width is simulated)
43 | - App is not working on Linux and Windows Operating Systems due to puppeteer and
44 | electron/chromium connection
45 | ([#8](https://github.com/nomadinteractive/screenshot-tracker/issues/8),
46 | [#11](https://github.com/nomadinteractive/screenshot-tracker/issues/11))
47 | - Testing on Wimdows
48 | - Testing on Linux
49 | - Writing proper react component tests. Right now tests folder only contains
50 | tests from original boilerplate code. They are not in proper use.
51 |
52 |
53 | ### Table of contents
54 |
55 | * [Install](#install)
56 | * [Usage](#usage)
57 | * [License](#license)
58 |
59 | ### Install
60 |
61 | #### Clone this repo
62 |
63 | ```
64 | git clone https://github.com/nomadinteractive/screenshot-tracker
65 | ```
66 |
67 | #### Install dependencies
68 |
69 | ```
70 | npm install
71 | ```
72 | or
73 | ```
74 | yarn
75 | ```
76 |
77 | ### Usage
78 |
79 | #### Run
80 |
81 | ```
82 | npm start
83 | ```
84 | or
85 | ```
86 | yarn start
87 | ```
88 |
89 | #### Build (manual)
90 |
91 | ```
92 | npm run build
93 | ```
94 | or
95 | ```
96 | yarn build
97 | ```
98 |
99 | #### Prod (Preview in Production)
100 |
101 | ```
102 | npm run prod
103 | ```
104 | or
105 | ```
106 | yarn prod
107 | ```
108 |
109 | #### Build package (Current OS)
110 |
111 | ```
112 | npm run package
113 | ```
114 | or
115 | ```
116 | yarn package
117 | ```
118 |
119 | #### Build package (Mac, Linux, Windows)
120 |
121 | ```
122 | npm run package:all
123 | ```
124 | or
125 | ```
126 | yarn package:all
127 | ```
128 |
129 | #### Test
130 |
131 | ```
132 | npm test
133 | ```
134 | or
135 | ```
136 | yarn test
137 | ```
138 |
139 | #### Docs
140 |
141 | ```
142 | npm run docs
143 | ```
144 | or
145 | ```
146 | yarn docs
147 | ```
148 |
149 | ### License
150 |
151 | MIT © [Nomad Interactive](https://github.com/nomadinteractive/screenshot-tracker/blob/master/LICENSE)
152 |
153 | Boilerplate derived from
154 | [Leonardo Rico](https://github.com/kevoj/electron-react-ant-boilerplate/blob/master/LICENSE)
155 | via MIT license
156 |
--------------------------------------------------------------------------------
/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | import { spawn } from 'child_process'
2 | import path from 'path'
3 | import webpack from 'webpack'
4 | import merge from 'webpack-merge'
5 | import HtmlWebpackPlugin from 'html-webpack-plugin'
6 | import postcssPresetEnv from 'postcss-preset-env'
7 | import AntdScssThemePlugin from 'antd-scss-theme-plugin'
8 | import BabiliPlugin from 'babili-webpack-plugin'
9 | import TerserPlugin from 'terser-webpack-plugin'
10 | import UglifyJsPlugin from 'uglifyjs-webpack-plugin'
11 | import BrotliPlugin from 'brotli-webpack-plugin'
12 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'
13 | import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin'
14 |
15 | const host = '0.0.0.0'
16 | const port = 3100
17 | const src = path.resolve(__dirname, 'src')
18 |
19 | const isDev = process.env.NODE_ENV === 'development'
20 |
21 | const cssModuleLoader = {
22 | loader: 'css-loader',
23 | options: {
24 | importLoaders: 2,
25 | modules: true,
26 | camelCase: true,
27 | sourceMap: isDev,
28 | localIdentName: isDev
29 | ? '[folder]__[name]__[local]__[hash:base64:5]'
30 | : '[hash:base64:5]'
31 | }
32 | }
33 |
34 | const cssLoader = {
35 | loader: 'css-loader',
36 | options: {
37 | importLoaders: 2,
38 | modules: false,
39 | sourceMap: isDev
40 | }
41 | }
42 |
43 | const postCssLoader = {
44 | loader: 'postcss-loader',
45 | options: {
46 | ident: 'postcss',
47 | sourceMap: isDev,
48 | plugins: () => [postcssPresetEnv()]
49 | }
50 | }
51 |
52 | const sassLoader = {
53 | loader: 'sass-loader',
54 | options: {
55 | sourceMap: isDev
56 | }
57 | }
58 |
59 | const lessLoader = AntdScssThemePlugin.themify({
60 | loader: 'less-loader',
61 | options: {
62 | sourceMap: isDev,
63 | javascriptEnabled: true
64 | }
65 | })
66 |
67 | const sassHotLoader = {
68 | loader: 'css-hot-loader'
69 | }
70 |
71 | const sassHotModuleLoader = {
72 | loader: 'css-hot-loader',
73 | options: {
74 | cssModule: true
75 | }
76 | }
77 |
78 | const assetsLoader = {
79 | loader: 'file-loader?name=[name]__[hash:base64:5].[ext]'
80 | }
81 |
82 | const babelLoader = [
83 | {
84 | loader: 'thread-loader'
85 | },
86 | {
87 | loader: 'babel-loader',
88 | options: {
89 | cacheDirectory: true
90 | }
91 | }
92 | ]
93 |
94 | const babelDevLoader = babelLoader.concat([
95 | 'react-hot-loader/webpack',
96 | 'eslint-loader'
97 | ])
98 |
99 | const config = {
100 | target: 'electron-renderer',
101 | base: {
102 | module: {
103 | rules: [
104 | {
105 | test: /\.(js|jsx)$/,
106 | use: isDev ? babelDevLoader : babelLoader,
107 | exclude: /node_modules/
108 | },
109 | {
110 | test: /\.(jpe?g|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$/,
111 | use: [assetsLoader]
112 | },
113 | {
114 | test: /\.global|vars\.scss$/,
115 | use: [
116 | sassHotLoader,
117 | MiniCssExtractPlugin.loader,
118 | cssLoader,
119 | postCssLoader,
120 | sassLoader
121 | ]
122 | },
123 | {
124 | test: /\.scss$/,
125 | exclude: /\.global|vars\.scss$/,
126 | use: [
127 | sassHotModuleLoader,
128 | MiniCssExtractPlugin.loader,
129 | cssModuleLoader,
130 | postCssLoader,
131 | sassLoader
132 | ]
133 | },
134 | {
135 | test: /\.(less|css)$/,
136 | use: [
137 | sassHotLoader,
138 | MiniCssExtractPlugin.loader,
139 | cssLoader,
140 | lessLoader
141 | ]
142 | }
143 | ]
144 | },
145 | plugins: [
146 | new webpack.DefinePlugin({
147 | NODE_ENV: process.env.NODE_ENV
148 | }),
149 | new AntdScssThemePlugin(
150 | path.join(__dirname, 'src', 'styles/ant.vars.scss')
151 | ),
152 | new MiniCssExtractPlugin({
153 | filename: isDev ? '[name].css' : '[name].[chunkhash].css',
154 | chunkFilename: isDev ? '[id].css' : '[name].[chunkhash].css',
155 | reload: false
156 | }),
157 | new HtmlWebpackPlugin({
158 | template: 'public/index.html',
159 | minify: {
160 | collapseWhitespace: !isDev
161 | }
162 | })
163 | ]
164 | },
165 | development: {
166 | mode: 'development',
167 | plugins: [new webpack.HotModuleReplacementPlugin()],
168 | entry: [
169 | 'react-hot-loader/patch',
170 | 'webpack/hot/only-dev-server',
171 | src
172 | ],
173 | devtool: 'cheap-module-source-map',
174 | cache: true,
175 | devServer: {
176 | host,
177 | port,
178 | hot: true,
179 | contentBase: 'public',
180 | compress: true,
181 | inline: true,
182 | lazy: false,
183 | // stats: 'errors-only',
184 | historyApiFallback: {
185 | verbose: true,
186 | disableDotRule: false
187 | },
188 | headers: { 'Access-Control-Allow-Origin': '*' },
189 | stats: {
190 | colors: true,
191 | chunks: false,
192 | children: false
193 | },
194 | before() {
195 | spawn('electron', ['.'], {
196 | shell: true,
197 | env: process.env,
198 | stdio: 'inherit'
199 | })
200 | .on('close', () => process.exit(0))
201 | .on('error', (spawnError) => console.error(spawnError))
202 | }
203 | },
204 | optimization: {
205 | namedModules: true
206 | },
207 | resolve: {
208 | extensions: ['.js', '.jsx', '.json'],
209 | modules: [].concat(src, ['node_modules']),
210 | alias: {
211 | 'react-dom': '@hot-loader/react-dom'
212 | }
213 | }
214 | },
215 | production: {
216 | mode: 'production',
217 | entry: {
218 | app: src
219 | },
220 | plugins: [
221 | new BrotliPlugin({
222 | asset: '[path].br[query]',
223 | test: /\.(js|css|html|svg)$/,
224 | threshold: 10240,
225 | minRatio: 0.8
226 | })
227 | ],
228 | output: {
229 | path: path.join(__dirname, '/dist'),
230 | filename: '[name].[chunkhash].js'
231 | },
232 | optimization: {
233 | minimizer: [
234 | new UglifyJsPlugin(),
235 | new TerserPlugin(),
236 | new BabiliPlugin(),
237 | new OptimizeCSSAssetsPlugin()
238 | ],
239 | splitChunks: {
240 | cacheGroups: {
241 | commons: {
242 | test: /[\\/]node_modules[\\/]/,
243 | name: 'vendors',
244 | chunks: 'all'
245 | }
246 | }
247 | }
248 | }
249 | }
250 | }
251 |
252 | export default merge(config.base, config[process.env.NODE_ENV])
253 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "screenshot-tracker",
3 | "version": "1.0.0",
4 | "description": "Screenshot tracking tool using electron/chromium and puppeteer",
5 | "license": "MIT",
6 | "private": false,
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/nomadinteractive/screenshot-tracker"
10 | },
11 | "author": {
12 | "name": "Nomad Interactive",
13 | "email": "hello@nomadinteractive.co",
14 | "url": "https://nomadinteractive.co"
15 | },
16 | "engines": {
17 | "node": ">=12.0.0",
18 | "npm": ">=5.0.0",
19 | "yarn": ">=1.0.0"
20 | },
21 | "homepage": "",
22 | "main": "./main.js",
23 | "scripts": {
24 | "start": "npm run env:dev -- webpack-dev-server",
25 | "build": "npm run env:prod -- webpack",
26 | "prod": "npm run build && electron --noDevServer .",
27 | "package": "npm run build",
28 | "package:all": "npm run build",
29 | "package:nosign": "CSC_IDENTITY_AUTO_DISCOVERY=false npm run package",
30 | "test": "jest -u",
31 | "lint": "eslint ./src",
32 | "docs": "jsdoc -r ./src -d docs --verbose",
33 | "env:dev": "cross-env NODE_ENV=development",
34 | "env:prod": "cross-env NODE_ENV=production",
35 | "build:clean": "rimraf -rf dist",
36 | "build:copy": "copyfiles -u 1 public/* public/**/* dist -e public/index.html",
37 | "prebuild": "npm run build:clean && npm run build:copy",
38 | "prepackage:all": "rimraf -rf release",
39 | "postpackage": "electron-builder build",
40 | "postpackage:all": "electron-builder build -mwl",
41 | "check": "nomad-codecheck ./src",
42 | "clean": "npm run lint && npm run check"
43 | },
44 | "dependencies": {
45 | "@sentry/electron": "^1.3.0",
46 | "antd": "^3.16.3",
47 | "chrome-launcher": "^0.13.4",
48 | "classnames": "^2.2.6",
49 | "darkmode-js": "^1.5.5",
50 | "electron-is-dev": "^1.1.0",
51 | "electron-store": "^5.1.0",
52 | "electron-window-state": "^5.0.3",
53 | "lighthouse": "^6.5.0",
54 | "moment": "^2.24.0",
55 | "prop-types": "^15.7.2",
56 | "puppeteer-core": "^2.1.1",
57 | "react": "^16.8.6",
58 | "react-dom": "^16.8.6",
59 | "react-hot-loader": "^4.12.19",
60 | "react-image-lightbox": "^5.1.1",
61 | "react-redux": "^7.2.0",
62 | "react-router-dom": "^5.0.0",
63 | "redux": "^4.0.5",
64 | "redux-thunk": "^2.3.0",
65 | "request": "^2.88.2",
66 | "slugify": "^1.4.0",
67 | "terser-webpack-plugin": "^2.3.4"
68 | },
69 | "devDependencies": {
70 | "electron": "^8.1.1",
71 | "@babel/core": "^7.4.3",
72 | "@babel/plugin-proposal-class-properties": "^7.4.0",
73 | "@babel/plugin-transform-modules-commonjs": "^7.4.3",
74 | "@babel/plugin-transform-runtime": "^7.4.3",
75 | "@babel/preset-env": "^7.4.3",
76 | "@babel/preset-react": "^7.0.0",
77 | "@babel/register": "^7.4.0",
78 | "@commitlint/cli": "^8.2.0",
79 | "@hot-loader/react-dom": "^16.8.6",
80 | "@nomadinteractive/commitlint-config": "1.0.3",
81 | "@nomadinteractive/eslint-config": "0.1.0",
82 | "@nomadinteractive/nomad-codecheck": "1.0.37",
83 | "antd-scss-theme-plugin": "^1.0.7",
84 | "babel-eslint": "^10.0.1",
85 | "babel-jest": "^24.7.1",
86 | "babel-loader": "^8.0.5",
87 | "babel-plugin-import": "^1.11.0",
88 | "babel-plugin-root-import": "^6.1.0",
89 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
90 | "babili-webpack-plugin": "^0.1.2",
91 | "brotli-webpack-plugin": "^1.1.0",
92 | "copyfiles": "^2.1.0",
93 | "cross-env": "^5.2.0",
94 | "css-hot-loader": "^1.4.4",
95 | "css-loader": "^2.1.1",
96 | "electron-builder": "^22.4.1",
97 | "electron-notarize": "^0.2.1",
98 | "enzyme": "^3.9.0",
99 | "enzyme-adapter-react-16": "^1.12.1",
100 | "enzyme-to-json": "^3.3.5",
101 | "eslint": "^5.16.0",
102 | "eslint-loader": "^2.1.2",
103 | "eslint-plugin-react": "^7.12.4",
104 | "file-loader": "^3.0.1",
105 | "html-webpack-plugin": "^3.2.0",
106 | "husky": "^3.0.9",
107 | "jest": "^24.7.1",
108 | "jest-cli": "^24.7.1",
109 | "jsdoc": "^3.5.5",
110 | "less": "^3.9.0",
111 | "less-loader": "^4.1.0",
112 | "mini-css-extract-plugin": "^0.6.0",
113 | "node-sass": "^4.11.0",
114 | "optimize-css-assets-webpack-plugin": "^5.0.1",
115 | "postcss-import": "^12.0.1",
116 | "postcss-loader": "^3.0.0",
117 | "postcss-nested": "^4.1.2",
118 | "postcss-preset-env": "^6.6.0",
119 | "postcss-pxtorem": "^4.0.1",
120 | "rimraf": "^2.6.3",
121 | "sass-loader": "^7.1.0",
122 | "style-loader": "^0.23.1",
123 | "thread-loader": "^2.1.2",
124 | "uglifyjs-webpack-plugin": "^2.1.2",
125 | "webpack": "^4.30.0",
126 | "webpack-cli": "^3.3.0",
127 | "webpack-dev-server": "^3.3.1",
128 | "webpack-merge": "^4.2.1"
129 | },
130 | "commitlint": {
131 | "extends": [
132 | "@nomadinteractive/commitlint-config"
133 | ]
134 | },
135 | "husky": {
136 | "hooks": {
137 | "pre-commit": "npm run clean",
138 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
139 | }
140 | },
141 | "build": {
142 | "productName": "Screenshot Tracker",
143 | "appId": "co.nomadinteractive.screenshot-tracker",
144 | "afterSign": "scripts/notarize.js",
145 | "files": [
146 | "dist",
147 | "main.js",
148 | "storage.js",
149 | "sentry.js",
150 | "package.json"
151 | ],
152 | "mac": {
153 | "icon": "./public/appicon_512.png",
154 | "target": "pkg",
155 | "hardenedRuntime": true,
156 | "gatekeeperAssess": false,
157 | "entitlements": "build/entitlements.mac.plist",
158 | "entitlementsInherit": "build/entitlements.mac.plist"
159 | },
160 | "win": {
161 | "icon": "./public/appicon_256.png",
162 | "target": [
163 | "nsis",
164 | "msi"
165 | ]
166 | },
167 | "linux": {
168 | "target": [
169 | "deb",
170 | "rpm",
171 | "snap",
172 | "AppImage"
173 | ],
174 | "category": "Development"
175 | },
176 | "directories": {
177 | "buildResources": "public",
178 | "output": "release"
179 | }
180 | },
181 | "jest": {
182 | "snapshotSerializers": [
183 | "enzyme-to-json/serializer"
184 | ],
185 | "setupFiles": [
186 | "raf/polyfill",
187 | "/@test/config/setupTests.js"
188 | ],
189 | "moduleNameMapper": {
190 | "^.+\\.(css|less|scss)$": "babel-jest",
191 | "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/@test/config/fileMock.js",
192 | "^components$": "/@test/config/componentsMock.js"
193 | },
194 | "moduleDirectories": [
195 | "src",
196 | "@test/src",
197 | "node_modules"
198 | ]
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const fs = require('fs')
3 | const url = require('url')
4 | const isDev = require('electron-is-dev')
5 | const windowStateKeeper = require('electron-window-state')
6 | const puppeteer = require('puppeteer-core')
7 | const chromeLauncher = require('chrome-launcher')
8 | const lighthouse = require('lighthouse')
9 | const slugify = require('slugify')
10 | const request = require('request')
11 | const util = require('util')
12 | const {
13 | app,
14 | BrowserWindow,
15 | Menu,
16 | ipcMain,
17 | crashReporter
18 | } = require('electron') // eslint-disable-line
19 |
20 | const storage = require('./storage')
21 |
22 | require('./sentry')
23 |
24 | crashReporter.start({
25 | companyName: 'Nomad Interactive',
26 | productName: 'Screenshot Tracker',
27 | ignoreSystemCrashHandler: true,
28 | submitURL: 'https://sentry.io/api/4867322/minidump/?sentry_key=09f1b01e6e5d40d5a6e5a8135cc5cd55'
29 | })
30 |
31 | // Clean storage for testing
32 | // storage.clearRuns() // for testing
33 |
34 | const appDocumentsDirectory = path.join(app.getPath('documents'), '/screenshot-tracker')
35 | console.log('--> appDocumentsDirectory', appDocumentsDirectory)
36 | if (!fs.existsSync(appDocumentsDirectory)) {
37 | fs.mkdirSync(appDocumentsDirectory)
38 | console.log('==> appDocumentsDirectory was not exists created!')
39 | }
40 |
41 |
42 | // Configure a headleass chrome and pupeteer instance
43 |
44 | const chromeLauncherOpts = {
45 | chromeFlags: ['--headless'],
46 | logLevel: 'info',
47 | output: 'html'
48 | }
49 |
50 | let chrome
51 | let browser
52 |
53 | (async () => {
54 | chrome = await chromeLauncher.launch(chromeLauncherOpts)
55 | chromeLauncherOpts.port = chrome.port
56 |
57 | const resp = await util.promisify(request)(`http://localhost:${chromeLauncherOpts.port}/json/version`)
58 | const { webSocketDebuggerUrl } = JSON.parse(resp.body)
59 | browser = await puppeteer.connect({
60 | browserWSEndpoint: webSocketDebuggerUrl,
61 | defaultViewport: null
62 | })
63 | })()
64 |
65 |
66 | // Configure application main window
67 |
68 | let mainWindow
69 |
70 | if (process.platform === 'win32') {
71 | app.commandLine.appendSwitch('high-dpi-support', 'true')
72 | app.commandLine.appendSwitch('force-device-scale-factor', '1')
73 | }
74 |
75 | const createWindow = () => {
76 | const mainWindowState = windowStateKeeper({
77 | path: app.getPath('userData'),
78 | defaultWidth: 1024,
79 | defaultHeight: 768
80 | })
81 |
82 | mainWindow = new BrowserWindow({
83 | x: mainWindowState.x,
84 | y: mainWindowState.y,
85 | width: mainWindowState.width,
86 | height: mainWindowState.height,
87 | show: false,
88 | webPreferences: {
89 | webSecurity: false,
90 | nodeIntegration: true,
91 | preload: path.join(__dirname, 'sentry.js')
92 | }
93 | })
94 |
95 | mainWindowState.manage(mainWindow)
96 |
97 | let indexPath
98 |
99 | if (isDev && process.argv.indexOf('--noDevServer') === -1) {
100 | indexPath = url.format({
101 | protocol: 'http:',
102 | host: 'localhost:3100',
103 | pathname: 'index.html',
104 | slashes: true
105 | })
106 | }
107 | else {
108 | indexPath = url.format({
109 | protocol: 'file:',
110 | pathname: path.join(__dirname, 'dist', 'index.html'),
111 | slashes: true
112 | })
113 | }
114 |
115 | mainWindow.loadURL(indexPath)
116 |
117 | mainWindow.once('ready-to-show', () => {
118 | mainWindow.show()
119 |
120 | if (isDev) {
121 | mainWindow.webContents.openDevTools()
122 |
123 | mainWindow.webContents.on('context-menu', (e, props) => {
124 | const { x, y } = props
125 |
126 | Menu.buildFromTemplate([
127 | {
128 | label: 'Inspect element',
129 | click: () => {
130 | mainWindow.inspectElement(x, y)
131 | }
132 | }
133 | ]).popup(mainWindow)
134 | })
135 | }
136 | })
137 |
138 | mainWindow.on('closed', () => {
139 | mainWindow = null
140 | })
141 |
142 | ipcMain.on('take-screenshots-of-a-run', (event, arg) => {
143 | startRun(arg.id)
144 | })
145 | }
146 |
147 | app.on('ready', () => {
148 | createWindow()
149 | })
150 |
151 | app.on('window-all-closed', () => {
152 | if (process.platform !== 'darwin') {
153 | app.quit()
154 | }
155 | })
156 |
157 | app.on('activate', () => {
158 | if (mainWindow === null) {
159 | createWindow()
160 | }
161 | })
162 |
163 | app.on('before-quit', async () => {
164 | console.log('--> App quit clean-up. Closing browser and chrome instances')
165 | await browser.disconnect()
166 | await chrome.kill()
167 | })
168 |
169 | const startRun = async (runId) => {
170 | const run = storage.getRun(runId)
171 | const runDirectory = appDocumentsDirectory + '/' + run.id + '-'
172 | + slugify(run.name.replace('/', '-').replace(':', '.'))
173 | // console.log('--> runDirectory', runDirectory)
174 | if (!fs.existsSync(runDirectory)) { fs.mkdirSync(runDirectory) }
175 | if (run && run.pages) {
176 | console.log('--> run Object:', run)
177 | for (let i = 0; i < run.pages.length; i += 1) {
178 | const page = run.pages[i]
179 | const resolutions = Object.keys(page.screenshots)
180 | for (let j = 0; j < resolutions.length; j += 1) {
181 | const resolution = resolutions[j]
182 | const screenshotFilePath = runDirectory + '/'
183 | + slugify(page.url).replace(/https?/g, '').replace(/[./:]/g, '') + '-' + resolution + '.jpg'
184 | // console.log('--> screenshotFilePath', screenshotFilePath)
185 | run.pages[i].screenshots[resolution].file = screenshotFilePath
186 | let viewportWidth = 1440
187 | if (resolution === 'desktopLarge') viewportWidth = 1920
188 | if (resolution === 'tabletLandscape') viewportWidth = 1024
189 | if (resolution === 'tabletPortrait') viewportWidth = 768
190 | if (resolution === 'mobile') viewportWidth = 350
191 | // eslint-disable-next-line
192 | const screenshotResult = await takeScreenshotOfWebpage(page.url, viewportWidth, screenshotFilePath, run.delay)
193 | // console.log('--> screenshotResult', screenshotResult)
194 | if (screenshotResult) run.pages[i].screenshots[resolution].status = 'success'
195 | else run.pages[i].screenshots[resolution].status = 'failed'
196 | // console.log('--> updatedRun', run)
197 | storage.updateRun(runId, run)
198 | mainWindow.webContents.send('run-updated', run)
199 | }
200 |
201 | // Run lighthouse report
202 | if (run.options && run.options.lighthouse) {
203 | const { lhr, report } = await runLighthouseReport(page.url) // eslint-disable-line
204 | const categories = Object.values(lhr.categories)
205 | // console.log(`--> [LHR] ${categories.map((c) => c.id + ': ' + c.score).join(', ')} for ${page.url}`)
206 | // console.log('--> lhr report', report) // html
207 | // console.log('--> lhr.categories', lhr.categories) // scores
208 | const scores = {}
209 | categories.map((c) => {
210 | scores[c.id] = c.score
211 | return c
212 | })
213 | run.pages[i].lhrScores = scores
214 |
215 | // Save report html in screenshots directory
216 | const lhReportFilePath = runDirectory + '/'
217 | + slugify(page.url).replace(/https?/g, '').replace(/[./:]/g, '') + '-lighthouse-report.html'
218 | fs.writeFileSync(lhReportFilePath, report)
219 |
220 | run.pages[i].lhrHtmlPath = lhReportFilePath
221 |
222 | storage.updateRun(runId, run)
223 | mainWindow.webContents.send('run-updated', run)
224 | }
225 | }
226 | // Mark run status as finished
227 | }
228 | }
229 |
230 | const runLighthouseReport = async (pageUrl) => {
231 | const lhrResult = await lighthouse(pageUrl, chromeLauncherOpts, null)
232 | return lhrResult
233 | }
234 |
235 | const takeScreenshotOfWebpage = async (testUrl, viewportWidth, filePath, delay) => {
236 | let returnVal = false
237 |
238 | const page = await browser.newPage()
239 | await page.setViewport({ width: viewportWidth, height: 500 })
240 |
241 | await page.goto(testUrl)
242 | // lazy load issue :(
243 |
244 | try {
245 | // lazy load issue
246 | // await page.evaluate(() => { window.scrollBy(0, window.innerHeight) })
247 |
248 | if (delay > 0) {
249 | await new Promise((r) => setTimeout(r, delay * 1000))
250 | // await page.waitFor(delay * 1000)
251 | }
252 |
253 | await page.screenshot({
254 | path: filePath,
255 | type: 'jpeg',
256 | fullPage: true,
257 | })
258 | returnVal = true
259 | }
260 | catch (e) {
261 | console.log('----> Puppeteer save screenshot attempt failed!', e)
262 | }
263 |
264 | await page.close()
265 |
266 | return returnVal
267 | }
268 |
--------------------------------------------------------------------------------
/src/screens/new_run.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { Redirect } from 'react-router-dom'
3 | import { connect } from 'react-redux'
4 | import PropTypes from 'prop-types'
5 | import moment from 'moment'
6 | import {
7 | Button,
8 | Form,
9 | Icon,
10 | Input,
11 | Checkbox,
12 | PageHeader,
13 | notification
14 | } from 'antd'
15 |
16 | import { saveRun } from '../actions'
17 |
18 | import Layout from '../layout'
19 |
20 | const electron = window.require('electron')
21 | const storage = electron.remote.require('./storage')
22 |
23 | const resolutionButtonStyle = {
24 | // width: '20%',
25 | height: 120,
26 | paddingLeft: 40,
27 | paddingRight: 40,
28 | }
29 |
30 | const resolutionButtonIconStyle = {
31 | fontSize: 20,
32 | marginBottom: 10
33 | }
34 |
35 | // eslint-disable-next-line
36 | const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi
37 | const urlRegex = new RegExp(urlExpression)
38 |
39 | class NewRun extends Component {
40 | constructor(props) {
41 | super(props)
42 |
43 | this.state = {
44 | runIsSaved: false,
45 | newRunId: null,
46 | name: '',
47 | urls: '',
48 | delay: 0,
49 | resolutions: {
50 | desktop: true,
51 | desktopLarge: true,
52 | tabletPortrait: false,
53 | tabletLandscape: false,
54 | mobile: true,
55 | },
56 | options: {
57 | lighthouse: false,
58 | // meta: false,
59 | // og: false,
60 | // resource: false
61 | }
62 | }
63 | }
64 |
65 | componentDidMount() {
66 | const { resolutions } = this.state
67 | const newRunId = storage.getNextRunId()
68 | const lastRunObj = storage.getLastRunObj()
69 | // console.log('--> lastRunObj', lastRunObj)
70 | const updatedResolutions = { ...resolutions }
71 | const urlsArr = []
72 | if (lastRunObj && lastRunObj.pages) {
73 | for (let i = 0; i < lastRunObj.pages.length; i += 1) {
74 | urlsArr.push(lastRunObj.pages[i].url)
75 | updatedResolutions.desktop = Boolean(lastRunObj.pages[i].screenshots.desktop)
76 | updatedResolutions.desktopLarge = Boolean(lastRunObj.pages[i].screenshots.desktopLarge)
77 | updatedResolutions.tabletPortrait = Boolean(lastRunObj.pages[i].screenshots.tabletPortrait)
78 | updatedResolutions.tabletLandscape = Boolean(lastRunObj.pages[i].screenshots.tabletLandscape)
79 | updatedResolutions.mobile = Boolean(lastRunObj.pages[i].screenshots.mobile)
80 | }
81 | }
82 | const newRunStateKeys = {
83 | newRunId,
84 | name: `Run #${newRunId} - ${moment().format('M/D H:m a')}`,
85 | urls: urlsArr.join('\n') || 'https://apple.com\nhttps://medium.com',
86 | resolutions: updatedResolutions,
87 | delay: lastRunObj.delay,
88 | options: lastRunObj.options,
89 | }
90 | this.setState(newRunStateKeys)
91 | }
92 |
93 | handleResolutionOptionChange(res) {
94 | const { resolutions } = this.state
95 | this.setState({
96 | resolutions: {
97 | ...resolutions,
98 | [res]: !resolutions[res]
99 | }
100 | })
101 | }
102 |
103 | handleOptionsChange(opt) {
104 | const { options } = this.state
105 | this.setState({
106 | options: {
107 | ...options,
108 | [opt]: options ? !options[opt] : true
109 | }
110 | })
111 | }
112 |
113 | handleSubmit() {
114 | const { saveRunAction } = this.props
115 | const {
116 | name, urls, resolutions, delay, options
117 | } = this.state
118 |
119 | if (urls.length < 2) {
120 | return notification.error({
121 | message: 'Enter at least one url',
122 | })
123 | }
124 |
125 | const invalidUrls = []
126 | const urlsArr = urls.split('\n')
127 | urlsArr.forEach((url) => {
128 | if (!url.match(urlRegex)) {
129 | invalidUrls.push(url)
130 | }
131 | })
132 |
133 | if (invalidUrls.length > 0) {
134 | return notification.error({
135 | message: 'Following urls are not valid',
136 | description: (
137 |
138 | {invalidUrls.map((url) => ({url.length > 45 ? url.substr(0, 45) + '...' : url} ))}
139 |
140 | ),
141 | })
142 | }
143 |
144 | if (!(resolutions.desktop
145 | || resolutions.desktopLarge
146 | || resolutions.tabletLandscape
147 | || resolutions.tabletPortrait
148 | || resolutions.mobile)) {
149 | return notification.error({
150 | message: 'Select at least one screen size',
151 | })
152 | }
153 |
154 | const screenshots = {}
155 | Object.keys(resolutions).forEach((resolution) => {
156 | if (resolutions[resolution]) {
157 | screenshots[resolution] = {
158 | status: 'pending',
159 | file: null
160 | }
161 | }
162 | })
163 |
164 | const pagesArr = []
165 | urlsArr.forEach((url) => {
166 | pagesArr.push({
167 | url,
168 | screenshots
169 | })
170 | })
171 |
172 | const runObjToSave = {
173 | name,
174 | pages: pagesArr,
175 | resolutions,
176 | delay,
177 | options
178 | }
179 | saveRunAction(runObjToSave)
180 | // console.log('--> runObjToSave', runObjToSave)
181 | storage.saveLastRunObj(runObjToSave)
182 | this.setState({ runIsSaved: true })
183 | }
184 |
185 | render() {
186 | const {
187 | runIsSaved,
188 | newRunId,
189 | name,
190 | urls,
191 | delay,
192 | resolutions,
193 | options
194 | } = this.state
195 |
196 | if (runIsSaved) {
197 | return
198 | }
199 |
200 | return (
201 |
202 |
210 | // Copy From Last Run
211 | //
212 | // )}
213 | />
214 |
216 | { this.setState({ name: e.target.value }) }}
218 | value={name}
219 | />
220 |
221 |
222 | { this.setState({ urls: e.target.value }) }}
226 | value={urls}
227 | />
228 |
229 |
230 |
231 | { this.handleResolutionOptionChange('desktopLarge') }}
235 | >
236 |
237 |
238 | Desktop (Large)
239 |
240 | 1920px
241 |
242 | { this.handleResolutionOptionChange('desktop') }}
246 | >
247 |
248 |
249 | Desktop
250 |
251 | 1440px
252 |
253 | { this.handleResolutionOptionChange('tabletLandscape') }}
257 | >
258 |
262 |
263 | Tablet Landscape
264 |
265 | 1024px
266 |
267 | { this.handleResolutionOptionChange('tabletPortrait') }}
271 | >
272 |
273 |
274 | Tablet Portrait
275 |
276 | 768px
277 |
278 | { this.handleResolutionOptionChange('mobile') }}
282 | >
283 |
284 |
285 | Mobile
286 |
287 | 350px
288 |
289 |
290 |
291 |
292 | { this.setState({ delay: e.target.value }) }}
294 | value={delay}
295 | />
296 |
297 |
298 | {/* { this.handleOptionsChange('meta') }} checked={options.meta}>
299 | Page Meta Tags
300 | (Page Title, Meta Description, Keywords)
301 |
302 |
303 | { this.handleOptionsChange('og') }} checked={options.og}>
304 | OG Tags
305 | (Share Title, Message, Image)
306 |
307 | */}
308 | { this.handleOptionsChange('lighthouse') }}
310 | checked={options && options.lighthouse}
311 | >
312 | Run Lighthouse Report
313 |
314 | (Performance, SEO, Accesibility)
315 |
316 |
317 |
318 |
324 | Start Run
325 |
326 |
327 | {/* {JSON.stringify(this.state, null, 4)} */}
328 |
329 |
330 | )
331 | }
332 | }
333 |
334 | NewRun.propTypes = {
335 | saveRunAction: PropTypes.func.isRequired,
336 | }
337 |
338 | const mapStateToProps = () => ({ })
339 |
340 | const mapDispatchToProps = (dispatch) => ({
341 | saveRunAction: saveRun(dispatch)
342 | })
343 |
344 | export default connect(mapStateToProps, mapDispatchToProps)(NewRun)
345 |
--------------------------------------------------------------------------------
/src/screens/run_result.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { Redirect } from 'react-router-dom'
4 | import PropTypes from 'prop-types'
5 | import {
6 | Table,
7 | Input,
8 | Button,
9 | Menu,
10 | Dropdown,
11 | Icon,
12 | Modal,
13 | Tooltip,
14 | } from 'antd'
15 | import Lightbox from 'react-image-lightbox'
16 |
17 | import { listRuns } from '../actions'
18 | import Layout from '../layout'
19 |
20 | // const path = window.require('path')
21 | const { remote } = window.require('electron')
22 | const { shell } = remote
23 | const storage = remote.require('./storage')
24 |
25 | const fs = remote.require('fs')
26 |
27 | const getImageBase64Data = (filepath) => {
28 | if (typeof filepath !== 'string') return
29 | if (!fs.existsSync(filepath)) return
30 | const imgBase64 = fs.readFileSync(filepath).toString('base64')
31 | return 'data:image/png;base64,' + imgBase64
32 | }
33 |
34 | const loading =
35 |
36 | class RunResult extends Component {
37 | constructor(props) {
38 | super(props)
39 |
40 | this.state = {
41 | redirect: false,
42 | redirectTo: null,
43 | run: null,
44 | columns: [],
45 | lightboxImages: [],
46 | lightboxImageIndex: 0,
47 | lightboxIsVisible: false,
48 | renamedRunName: '',
49 | isInRenameRunMode: false,
50 | }
51 | }
52 |
53 | componentDidMount() {
54 | const { listRunsAction } = this.props
55 | listRunsAction()
56 | this.getRun()
57 | }
58 |
59 | componentDidUpdate(previousProps) {
60 | const { runs } = this.props
61 | if (runs !== previousProps.runs) {
62 | this.getRun()
63 | }
64 | }
65 |
66 | getRun() {
67 | const { runs } = this.props
68 | const { runId } = this.props.match.params // eslint-disable-line
69 |
70 | // const runs = listRunsAction()
71 | let runObj = null
72 | runs.forEach((run) => {
73 | if (parseInt(run.id, 10) === parseInt(runId, 10)) {
74 | runObj = run
75 | }
76 | })
77 |
78 | // Get base64 of the images read from the FS
79 | const lightboxImages = []
80 | if (runObj && runObj.pages) {
81 | runObj.pages = runObj.pages.map((page) => {
82 | const updatedPage = { ...page }
83 | Object.keys(page.screenshots).forEach((resolution) => {
84 | updatedPage.screenshots[resolution].imageb64 = getImageBase64Data(page.screenshots[resolution].file)
85 | lightboxImages.push(updatedPage.screenshots[resolution].imageb64)
86 | })
87 | return updatedPage
88 | })
89 | }
90 |
91 | this.setState({
92 | run: runObj,
93 | lightboxImages
94 | })
95 | this.setTableColumns(runObj)
96 | }
97 |
98 | // eslint-disable-next-line
99 | getResolutionColumnMeta(screenshotResName) {
100 | return {
101 | title: screenshotResName.substr(0, 1).toUpperCase() + screenshotResName.substr(1),
102 | dataIndex: 'screenshots.' + screenshotResName,
103 | key: 'screenshots.' + screenshotResName,
104 | render: (text, record) => (
105 |
106 | {record.screenshots[screenshotResName].status === 'success' && (
107 |
108 | {record.screenshots[screenshotResName].imageb64 ? (
109 |
110 | {/* eslint-disable-next-line */}
111 |
{
116 | // eslint-disable-next-line
117 | this.openLightboxWithScreenshot(record.screenshots[screenshotResName].imageb64)
118 | }}
119 | />
120 |
121 | ) : (
122 |
123 | {/* eslint-disable-next-line */}
124 |
125 |
126 |
127 |
128 | )}
129 |
130 | )}
131 | {record.screenshots[screenshotResName].status === 'pending' && (
132 | loading
133 | )}
134 | {record.screenshots[screenshotResName].status === 'failed' && (
135 |
136 | )}
137 |
138 | )
139 | }
140 | }
141 |
142 | setTableColumns(run) {
143 | if (!run) return
144 |
145 | const columns = [
146 | {
147 | title: 'URL',
148 | dataIndex: 'url',
149 | key: 'url',
150 | render: (text) => (
151 |
152 | {text}
153 | {/* open the link in OS browser from electron: http://bit.ly/38gOO9g */}
154 |
155 | ),
156 | },
157 | ]
158 |
159 | if (run.pages[0] && run.pages[0].screenshots) {
160 | if (run.pages[0].screenshots.desktopLarge) columns.push(this.getResolutionColumnMeta('desktopLarge'))
161 | if (run.pages[0].screenshots.desktop) columns.push(this.getResolutionColumnMeta('desktop'))
162 | if (run.pages[0].screenshots.tabletLandscape) columns.push(this.getResolutionColumnMeta('tabletLandscape'))
163 | if (run.pages[0].screenshots.tabletPortrait) columns.push(this.getResolutionColumnMeta('tabletPortrait'))
164 | if (run.pages[0].screenshots.mobile) columns.push(this.getResolutionColumnMeta('mobile'))
165 | }
166 |
167 | console.log('--> run', run)
168 |
169 | if (run.options && run.options.lighthouse) {
170 | columns.push({
171 | title: 'Performance',
172 | dataIndex: 'lhr-performance',
173 | key: 'lhr-performance',
174 | render: (text, record) => (
175 |
176 | {(record.lhrScores && record.lhrScores.performance)
177 | ? record.lhrScores.performance * 100 : loading}
178 |
179 | )
180 | })
181 | columns.push({
182 | title: 'SEO',
183 | dataIndex: 'lhr-seo',
184 | key: 'lhr-seo',
185 | render: (text, record) => (
186 |
187 | {(record.lhrScores && record.lhrScores.seo)
188 | ? record.lhrScores.seo * 100 : loading}
189 |
190 | )
191 | })
192 | columns.push({
193 | title: 'Accessibility',
194 | dataIndex: 'lhr-accessibility',
195 | key: 'lhr-accessibility',
196 | render: (text, record) => (
197 |
198 | {(record.lhrScores && record.lhrScores.accessibility)
199 | ? record.lhrScores.accessibility * 100 : loading}
200 |
201 | )
202 | })
203 | columns.push({
204 | title: 'LH Report',
205 | dataIndex: 'lhr-report',
206 | key: 'lhr-report',
207 | render: (text, record) => (record.lhrHtmlPath ? (
208 |
209 | {
212 | if (record.lhrHtmlPath) shell.showItemInFolder(record.lhrHtmlPath)
213 | }}
214 | >
215 |
216 |
217 |
218 | ) : loading)
219 | })
220 | }
221 |
222 | // columns.push({
223 | // key: 'actions',
224 | // render: () => (
225 | //
226 | //
227 | //
228 | //
229 | //
230 | // )
231 | // })
232 |
233 | // console.log('--> columns', columns)
234 |
235 | this.setState({ columns })
236 | }
237 |
238 | openLightboxWithScreenshot(b64) {
239 | const { lightboxImages } = this.state
240 | const lightboxImageIndex = lightboxImages.indexOf(b64)
241 | this.setState({
242 | lightboxIsVisible: true,
243 | lightboxImageIndex
244 | })
245 | }
246 |
247 | deleteRun() {
248 | const { run } = this.state
249 | const { listRunsAction } = this.props
250 |
251 | Modal.confirm({
252 | content: 'Are you sure you want to delete this run?',
253 | okText: 'Delete',
254 | okType: 'danger',
255 | onOk: () => {
256 | storage.deleteRun(run.id)
257 | listRunsAction()
258 | this.setState({
259 | redirect: true,
260 | redirectTo: '/',
261 | })
262 | }
263 | })
264 | }
265 |
266 | renameRun() {
267 | const { listRunsAction } = this.props
268 | const { run, renamedRunName } = this.state
269 | const updatedRun = { ...run }
270 | updatedRun.name = renamedRunName
271 | storage.updateRun(run.id, updatedRun)
272 | this.setState({
273 | run: updatedRun,
274 | isInRenameRunMode: false
275 | })
276 | listRunsAction()
277 | }
278 |
279 | openRunFolder() {
280 | const { run } = this.state
281 | if (run.pages && run.pages[0] && run.pages[0].screenshots) {
282 | const aScreenshotFile = Object.values(run.pages[0].screenshots)[0].file
283 | // const runFolderPath = path.dirname(aScreenshotFile)
284 | shell.showItemInFolder(aScreenshotFile)
285 | }
286 | }
287 |
288 | render() {
289 | const {
290 | redirect,
291 | redirectTo,
292 | run,
293 | columns,
294 | lightboxImages,
295 | lightboxImageIndex,
296 | lightboxIsVisible,
297 | isInRenameRunMode
298 | } = this.state
299 |
300 | if (redirect) {
301 | return (
302 |
303 | )
304 | }
305 |
306 | return (
307 |
308 |
309 | {run && (
310 |
318 |
327 | {!isInRenameRunMode && (
328 |
329 | {run.name}
330 | {
334 | this.setState({ isInRenameRunMode: true })
335 | }}
336 | >
337 |
338 |
339 |
340 | )}
341 | {isInRenameRunMode && (
342 |
343 |
344 | { this.setState({ renamedRunName: e.target.value }) }}
347 | style={{ width: '50%' }}
348 | />
349 | Rename
350 | {
352 | this.setState({ isInRenameRunMode: false })
353 | }}
354 | >
355 |
356 |
357 |
358 |
359 | )}
360 |
361 |
371 |
376 | Open Screenshots Folder
377 |
378 | {/*
379 |
380 | */}
381 |
385 | {/* Export */}
386 |
389 | Delete
390 |
391 |
392 | )}
393 | >
394 |
395 |
396 |
397 |
398 |
399 |
400 | )}
401 | {run && run.pages ? (
402 |
410 | ) : (
411 | Loading...
412 | )}
413 |
414 | {lightboxIsVisible && (
415 |
this.setState({ lightboxIsVisible: false })}
421 | onMovePrevRequest={() => {
422 | this.setState({
423 | // eslint-disable-next-line
424 | lightboxImageIndex: (lightboxImageIndex + lightboxImages.length - 1) % lightboxImages.length,
425 | })
426 | }}
427 | onMoveNextRequest={() => {
428 | this.setState({
429 | lightboxImageIndex: (lightboxImageIndex + 1) % lightboxImages.length,
430 | })
431 | }}
432 | />
433 | )}
434 |
435 | )
436 | }
437 | }
438 |
439 | RunResult.propTypes = {
440 | listRunsAction: PropTypes.func.isRequired,
441 | runs: PropTypes.array.isRequired // eslint-disable-line
442 | }
443 |
444 | const mapStateToProps = (state) => ({
445 | runs: state.runs
446 | })
447 |
448 | const mapDispatchToProps = (dispatch) => ({
449 | listRunsAction: listRuns(dispatch)
450 | })
451 |
452 | export default connect(mapStateToProps, mapDispatchToProps)(RunResult)
453 |
--------------------------------------------------------------------------------