├── .eslintrc
├── .gitignore
├── .travis.yml
├── Alchemy.png
├── Alchemy.yml
├── CODE_OF_CONDUCT.md
├── LICENSE.md
├── README.md
├── bin
├── fetchPhotosorcery
├── photosorcery
│ ├── photosorcery-darwin
│ ├── photosorcery-linux
│ └── photosorcery-windows.exe
└── release.sh
├── img
├── failure.png
├── iconTemplate.png
├── iconTemplate@2x.png
├── out.icns
└── success.png
├── index.html
├── main.js
├── package-lock.json
├── package.json
├── src
├── api.js
├── components
│ ├── Converter.jsx
│ ├── FileSorter.jsx
│ ├── Message.jsx
│ ├── Staging.jsx
│ ├── Tray.jsx
│ └── svg
│ │ ├── ArrowDown.jsx
│ │ ├── Cancel.jsx
│ │ ├── Convert.jsx
│ │ ├── Converting.jsx
│ │ ├── Done.jsx
│ │ ├── Failed.jsx
│ │ ├── Idle.jsx
│ │ ├── Merge.jsx
│ │ └── Settings.jsx
├── helpers
│ ├── configure.js
│ ├── constants.js
│ ├── functional.js
│ ├── menu.js
│ ├── notifier
│ │ ├── index.js
│ │ └── text.json
│ └── util.js
└── index.jsx
├── styles
├── color.scss
├── index.scss
└── keyframe.scss
├── test
└── util.test.js
├── webpack.config.js
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "installedESLint": true,
4 | "rules": {
5 | "no-var": 0,
6 | "func-names": 0,
7 | "space-before-function-paren": 0,
8 | "comma-dangle": 0,
9 | "semi": ["error", "never"],
10 | "no-unexpected-multiline": 1,
11 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
12 | "max-len": 0,
13 | "react/prop-types": 0,
14 | "arrow-body-style": 0,
15 | "no-use-before-define": ["error", { "functions": false, "classes": true }],
16 | "no-multi-spaces": ["error", { "exceptions": { "VariableDeclarator": true } }],
17 | "curly": 0,
18 | "no-nested-ternary": 0,
19 | "no-unused-vars": 2,
20 | "no-shadow": 0,
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | public
3 | packaged
4 |
5 | .DS_Store
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '6'
4 | before_deploy:
5 | - 'yarn run build'
6 | - 'yarn run package'
7 | - 'yarn run zip'
8 | deploy:
9 | provider: releases
10 | api_key:
11 | secure: V+pC1E/0CnhPcXr/BF4S3mVFzEQe7OInc6h8rpw9eXS5GrV2x3ehaUrsZCu+SEU5m8l8R95Lq6b2FScSHIj0NyWfpKf2J/a/TwQnvpOeZ0QDFVe6sUHDv8BGH+aQmyfH6Wd/M+t90gkDrFx0W+skm5t91ohkjJLlUF3afvxw2XpGO12//LS7dfCxffpWZcdV4DHN8AGJ0Kxu7wKBJIzeTIhdcJhmLmHo70PlDF0tJOXb6kFyHmD59vv0RjKBLTH10JQ9uaVjRc9+wuqTGEm6kgLTHfx3TWgSDQxn6bOiTnwdHZkSqeTRzdWilNVeX2fW0gq732L+wOYwx2krmJfl7m7XOi3YDutoVeHiBQt0FwFwsEWBQb3lDK/7xazYvob56wR2BFNIDcTsKIPewgIO0WEi/6Sz+XReBwn3EnPBKT4O6YOownBpMy9vzCVv5i/NQdhQs+qIlTOrpt42KxVOPLZ4VpqFR82PxSV9lmJK0db+d/CxLcT98n1WQsYO86p5enD1O6CvTw2fgG7tm4DPeKuOeTzX93T6BDx74JvrmvPq0phUa+dVJ/uCI4Dv+4gqKb1F22hJ/e7KssDs4XQmDpw7J3IMZySwmOq9hNBMETZrO9hIbuKZjTynw1LHK5Sbj0vh6+A4ZhG5hHIhYM0uNN2eO5kqKIqSzLs9t0gj9gw=
12 | file: ./packaged/Alchemy.zip
13 | skip_cleanup: true
14 | on:
15 | tags: true
16 |
--------------------------------------------------------------------------------
/Alchemy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/Alchemy.png
--------------------------------------------------------------------------------
/Alchemy.yml:
--------------------------------------------------------------------------------
1 | name: Alchemy
2 | description: Alchemy is a drag-and-drop file converter for your menubar.
3 | website: http://dawnlabs.io/alchemy
4 | repository: https://github.com/dawnlabs/alchemy
5 | keywords:
6 | - drag and drop
7 | - files
8 | - convert
9 |
--------------------------------------------------------------------------------
/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 contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hi@dawnlabs.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 dawn
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://app.fossa.io/projects/git%2Bgithub.com%2Fdawnlabs%2Falchemy?ref=badge_shield)
4 |
5 | ## Introduction
6 |
7 | Alchemy is an open-source file converter (built on Electron and React). It also supports operations like merging files together into a pdf. We hope to support more operations and file types soon!
8 |
9 | Visit [dawnlabs.io/alchemy/](http://dawnlabs.io/alchemy/) or read [our post](https://dawnlabs.github.io/blog/alchemy/) to learn more.
10 |
11 | ## Features
12 |
13 | * **Beautifully simple**. Super easy, drag-and-drop interface for converting/merging files
14 | * **Merge files**. Merge multiple images into one pdf, you can even change the file order
15 | * **Convert files**. Batch-convert multiple files to a variety of file types
16 |
17 | ## Usage
18 |
19 | []()
20 |
21 | 1. Simply open the app by clicking on the menubar icon, or by hitting `⌘-⇧-8`.
22 | 2. Then just drag your image files into the application and select any options.
23 | 3. Finally, click the convert/merge to seamlessly convert them into a variety of formats, or merge them into a pdf or gif. You can also hold `⇧` while dropping to merge the files into a pdf immediately.
24 |
25 | ## Installation
26 |
27 | ### macOS/Windows
28 |
29 | Download the latest release from the [Alchemy Releases Page](https://github.com/dawnlabs/alchemy/releases/latest)
30 |
31 | ### Linux
32 |
33 | We haven't tested Alchemy on Linux yet, but if you're feeling adventurous go right ahead and [create a build](https://github.com/dawnlabs/alchemy/issues/9) for your platform of choice.
34 |
35 | ## Contribute
36 |
37 | If you have discovered a bug or have a feature suggestion, feel free to create an issue on Github.
38 |
39 | If you'd like to make some changes yourself, see the following:
40 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device
41 | 2. Build the webpack bundle and start the application: `yarn build && yarn start`
42 | 3. Help us keep our code clean and safe: `npm i --package-lock-only && npm audit fix`
43 | 4. Run the tests with: `yarn test`
44 | 5. Finally, submit a [pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) with your changes!
45 |
46 | ## Authors
47 | Alchemy is a project by
48 | - Mike Fix ([@mfix22](https://github.com/mfix22))
49 | - Brian Dennis ([@briandennis](https://github.com/briandennis))
50 | - Jake Dexheimer ([@jakedex](https://github.com/jakedex))
51 |
52 |
53 | ## License
54 | [](https://app.fossa.io/projects/git%2Bgithub.com%2Fdawnlabs%2Falchemy?ref=badge_large)
55 |
--------------------------------------------------------------------------------
/bin/fetchPhotosorcery:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /* eslint-disable no-console, import/no-extraneous-dependencies */
3 | const path = require('path')
4 | const fs = require('fs-extra')
5 | const glob = require('glob')
6 | const chalk = require('chalk')
7 |
8 | const DEFAULT_BINARY_DIR = `${process.env.GOPATH}/src/github.com/dawnlabs/photosorcery`
9 | const PHOTOSORCERY_DIR = path.join(__dirname, 'photosorcery')
10 | const BINARY_IDENTIFIER = 'amd64'
11 | const BINARY_DELIMITER = '-'
12 | const BINARY_GLOB = `*${BINARY_IDENTIFIER}*`
13 |
14 | execute(process.argv[2] || DEFAULT_BINARY_DIR)
15 | .then(() => console.log(chalk.green('Success: Files fetched.')))
16 | .catch(console.error)
17 |
18 | async function execute (dir) {
19 | await copyBinaries(dir)
20 | await renameBinaries()
21 | }
22 |
23 | async function copyBinaries (dir) {
24 | const copyGlob = path.join(dir, BINARY_GLOB)
25 | const filesToCopy = glob.sync(copyGlob)
26 |
27 | filesToCopy.forEach(async (filename) => {
28 | await fs.copyFile(filename, path.join(PHOTOSORCERY_DIR, path.basename(filename)))
29 | })
30 | }
31 |
32 | async function renameBinaries () {
33 | const files = await fs.readdir(PHOTOSORCERY_DIR)
34 | const filesToRename = files.filter(filename => filename.includes(BINARY_IDENTIFIER))
35 |
36 | filesToRename.forEach(async (file) => {
37 | const normalizedFilename = removeFilenamePeriods(file)
38 | const parts = path.parse(normalizedFilename)
39 | const trimmedName = parts.name.split(BINARY_DELIMITER).slice(0, 2).join('-')
40 | const newFile = `${trimmedName}${parts.ext}`
41 |
42 | await fs.rename(path.join(PHOTOSORCERY_DIR, file), path.join(PHOTOSORCERY_DIR, newFile))
43 | })
44 | }
45 |
46 | function removeFilenamePeriods (filename) {
47 | const EXE_FLAG = filename.includes('.exe')
48 |
49 | let formatted = filename
50 | if (EXE_FLAG) {
51 | formatted = filename.slice(0, filename.length - 4)
52 | }
53 |
54 | formatted = filename.split('.').join('')
55 |
56 | if (EXE_FLAG) {
57 | formatted = `${filename}.exe`
58 | }
59 |
60 | return formatted
61 | }
62 |
--------------------------------------------------------------------------------
/bin/photosorcery/photosorcery-darwin:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/bin/photosorcery/photosorcery-darwin
--------------------------------------------------------------------------------
/bin/photosorcery/photosorcery-linux:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/bin/photosorcery/photosorcery-linux
--------------------------------------------------------------------------------
/bin/photosorcery/photosorcery-windows.exe:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/bin/photosorcery/photosorcery-windows.exe
--------------------------------------------------------------------------------
/bin/release.sh:
--------------------------------------------------------------------------------
1 | echo " Releasing $1 version"
2 | npm run lint
3 | npm run build:prod
4 | npm run package
5 | npm run zip
6 | npm version $1
7 | git push --tags
8 | release
9 |
--------------------------------------------------------------------------------
/img/failure.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/img/failure.png
--------------------------------------------------------------------------------
/img/iconTemplate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/img/iconTemplate.png
--------------------------------------------------------------------------------
/img/iconTemplate@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/img/iconTemplate@2x.png
--------------------------------------------------------------------------------
/img/out.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/img/out.icns
--------------------------------------------------------------------------------
/img/success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dawnlabs/alchemy/66d2465654bece596eaf37f82f6f8d18f75d0445/img/success.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Sanitizer
6 |
7 |
8 |
9 |
10 |
11 |
12 |
16 |
17 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | const menubar = require('menubar')
2 | const { ipcMain } = require('electron')
3 |
4 | const configure = require('./src/helpers/configure')
5 |
6 | const mb = menubar({
7 | alwaysOnTop: true,
8 | resizable: false,
9 | width: 292,
10 | height: 344,
11 | icon: `${__dirname}/img/iconTemplate.png`
12 | })
13 |
14 | mb.on('ready', () => {
15 | console.log('App started in menu bar.')
16 | configure(mb)
17 |
18 | mb.tray.on('drag-enter', () => mb.showWindow())
19 | })
20 |
21 | mb.on('focus-lost', () => mb.hideWindow());
22 |
23 | ipcMain.on('APP_PATH_REQUEST', (event) => {
24 | event.sender.send('APP_PATH_REPLY', mb.app.getAppPath())
25 | })
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "productName": "Alchemy",
3 | "main": "main.js",
4 | "engines": {
5 | "node": ">=9.11"
6 | },
7 | "scripts": {
8 | "start": "electron .",
9 | "lint": "eslint ./src/",
10 | "test": "jest",
11 | "build": "webpack --mode=development",
12 | "build:prod": "webpack --mode=production -p",
13 | "package": "electron-packager . --platform=darwin,win32,linux --arch=x64 --out=packaged --icon=img/out.icns --overwrite",
14 | "zip": "cd packaged/Alchemy-darwin-x64/ && zip -r -y ../Alchemy-mac.zip * && cd ../Alchemy-win32-x64 && zip -r -y ../Alchemy-win32.zip * && cd ..",
15 | "release": "./bin/release.sh $1",
16 | "fetch-photosorcery": "./bin/fetchPhotosorcery"
17 | },
18 | "devDependencies": {
19 | "@babel/core": "^7.0.0-beta.49",
20 | "@babel/preset-env": "^7.0.0-beta.49",
21 | "@babel/preset-react": "^7.0.0-beta.49",
22 | "babel-loader": "^8.0.0-beta",
23 | "chai": "^3.5.0",
24 | "chalk": "^2.4.1",
25 | "css-loader": "^0.28.11",
26 | "electron-packager": "^12.2.0",
27 | "eslint": "^3.13.1",
28 | "eslint-config-airbnb": "^14.0.0",
29 | "eslint-plugin-import": "^2.14.0",
30 | "eslint-plugin-jsx-a11y": "^3.0.2",
31 | "eslint-plugin-react": "^6.9.0",
32 | "extract-text-webpack-plugin": "^4.0.0-beta.0",
33 | "glob": "^7.1.2",
34 | "jest": "^19.0.2",
35 | "jest-cli": "^23.6.0",
36 | "node-sass": "^4.13.1",
37 | "release": "^4.0.2",
38 | "sass-loader": "^7.0.3",
39 | "style-loader": "^0.21.0",
40 | "webpack": "^4.21.0",
41 | "webpack-cli": "^3.0.2"
42 | },
43 | "dependencies": {
44 | "babel-polyfill": "^6.26.0",
45 | "electron": "^9.4.0",
46 | "execa": "^0.6.3",
47 | "fs-extra": "^6.0.1",
48 | "invariant": "^2.2.2",
49 | "menubar": "^5.2.3",
50 | "react": "^15.4.2",
51 | "react-dnd": "^2.1.4",
52 | "react-dnd-html5-backend": "^2.1.2",
53 | "react-dom": "^15.4.2",
54 | "react-sortable-hoc": "^0.6.2",
55 | "string": "^3.3.3",
56 | "tmp": "^0.0.33"
57 | },
58 | "name": "alchemy",
59 | "description": "Alchemy is a drag-and-drop file converter for your menubar.",
60 | "version": "0.5.1",
61 | "repository": {
62 | "type": "git",
63 | "url": "https://github.com/dawnlabs/alchemy.git"
64 | },
65 | "authors": [
66 | "Michael Fix <@mfix22>",
67 | "Jake Dexheimer <@jakedex>",
68 | "Brian Dennis <@briandennis>"
69 | ],
70 | "license": "MIT",
71 | "keywords": [
72 | "convert",
73 | "files",
74 | "images",
75 | "drag-and-drop"
76 | ]
77 | }
78 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | const execa = require('execa')
2 | const fs = require('fs-extra')
3 | const path = require('path')
4 | const tmp = require('tmp')
5 |
6 | let binary = null
7 |
8 | const copyToDir = (dir, filePath) => {
9 | const { base } = path.parse(filePath)
10 | const newFilePath = path.join(dir, base.replace(/\s+/g, ''))
11 |
12 | fs.copySync(filePath, newFilePath)
13 |
14 | return newFilePath
15 | }
16 |
17 | const renameWithPrefix = (filePath, index) => {
18 | const { dir, ext } = path.parse(filePath)
19 | const newPath = path.join(dir, `${index}${ext}`)
20 |
21 | fs.renameSync(filePath, newPath)
22 |
23 | return newPath
24 | }
25 |
26 | const isNonstandardFormat = extension => !['.gif', '.jpeg', '.jpg', '.png'].includes(extension.toLowerCase())
27 |
28 | module.exports = {
29 | async convert ({ files, outputPath, outputType }) {
30 | const tmpDir = tmp.dirSync().name
31 | const tmpFiles = files.map(copyToDir.bind(null, tmpDir))
32 |
33 | const args = [
34 | 'convert',
35 | '-type', outputType,
36 | '-out', outputPath,
37 | ...tmpFiles
38 | ]
39 |
40 | try {
41 | await execa(binary, args)
42 | } finally {
43 | fs.remove(tmpDir)
44 | }
45 | },
46 |
47 | async merge ({ files, outputPath }) {
48 | const tmpDir = tmp.dirSync().name
49 | let tmpFiles = files.map(copyToDir.bind(null, tmpDir))
50 |
51 | const extensions = tmpFiles.map(path.extname)
52 | if (extensions.some(isNonstandardFormat)) {
53 | // convert to filetypes that photosorcery merge can handle
54 | const normalizedDir = path.join(tmpDir, 'normalized')
55 | fs.mkdirSync(normalizedDir)
56 | tmpFiles = tmpFiles.map(renameWithPrefix)
57 |
58 | const args = [
59 | 'convert',
60 | '-type', 'png',
61 | '-out', normalizedDir,
62 | ...tmpFiles
63 | ]
64 |
65 | await execa(binary, args)
66 |
67 | tmpFiles = fs.readdirSync(normalizedDir).map(base => path.join(normalizedDir, base))
68 | }
69 |
70 | const args = [
71 | 'merge',
72 | '-out', outputPath,
73 | ...tmpFiles
74 | ]
75 |
76 | try {
77 | await execa(binary, args)
78 | } finally {
79 | fs.remove(tmpDir)
80 | }
81 | },
82 |
83 | init (appPath) {
84 | const basePath = path.join(appPath, 'bin', 'photosorcery')
85 |
86 | switch (process.platform) {
87 | case 'win32':
88 | binary = path.join(basePath, 'photosorcery-windows.exe')
89 | break
90 | case 'linux':
91 | binary = path.join(basePath, 'photosorcery-linux')
92 | break
93 | default:
94 | binary = path.join(basePath, 'photosorcery-darwin')
95 | break
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/Converter.jsx:
--------------------------------------------------------------------------------
1 | /* global window alert */
2 | import React, { Component } from 'react'
3 | import { NativeTypes } from 'react-dnd-html5-backend'
4 | import { DropTarget } from 'react-dnd'
5 | import S from 'string'
6 | import { arrayMove } from 'react-sortable-hoc'
7 |
8 | import Staging from './Staging'
9 | import Message from './Message'
10 | import Failed from './svg/Failed'
11 | import Done from './svg/Done'
12 | import Converting from './svg/Converting'
13 | import Idle from './svg/Idle'
14 |
15 | import { convert, merge } from '../api'
16 | import { notify } from '../helpers/notifier'
17 | import {
18 | getFileExtension,
19 | isValidFileType,
20 | uniqueFiles,
21 | createOutputFileName,
22 | filterImages,
23 | getUniqueExtensions
24 | } from '../helpers/util'
25 | import {
26 | fileTypes,
27 | MERGE,
28 | IDLE,
29 | STAGING,
30 | CONVERTING,
31 | FAILED,
32 | DONE,
33 | CONVERT
34 | } from '../helpers/constants'
35 |
36 | const drop = (props, monitor, component) => {
37 | const { files } = monitor.getItem()
38 | const stagingFiles = uniqueFiles(component.state.files, files)
39 | .filter(file =>
40 | isValidFileType(getFileExtension(file.name).toLowerCase()))
41 | component.setState({
42 | status: stagingFiles.length ? STAGING : IDLE,
43 | files: stagingFiles
44 | })
45 |
46 | if (component.state.shifted) {
47 | component.convert()
48 | }
49 | }
50 |
51 | const collect = (connect, monitor) => ({
52 | connectDropTarget: connect.dropTarget(),
53 | isOver: monitor.isOver(),
54 | canDrop: monitor.canDrop(),
55 | itemType: monitor.getItemType(),
56 | item: monitor.getItem()
57 | })
58 |
59 | const DEFAULT_STATE = {
60 | status: IDLE,
61 | operation: MERGE,
62 | outputType: 'pdf',
63 | files: [],
64 | inputValue: ''
65 | }
66 |
67 | class Converter extends Component {
68 | constructor (props) {
69 | super(props)
70 |
71 | this.state = DEFAULT_STATE
72 |
73 | this.isHover = this.isHover.bind(this)
74 | this.getIconObject = this.getIconObject.bind(this)
75 | this.convert = this.convert.bind(this)
76 | this.componentDidMount = this.componentDidMount.bind(this)
77 | this.handleOutputTypeChange = this.handleOutputTypeChange.bind(this)
78 | this.getFileName = this.getFileName.bind(this)
79 | }
80 |
81 | componentDidMount () {
82 | window.addEventListener('keydown', (e) => {
83 | if (e.key === 'Shift') {
84 | this.setState({
85 | shifted: true
86 | })
87 | }
88 | })
89 | window.addEventListener('keyup', () => {
90 | this.setState({
91 | shifted: false
92 | })
93 | })
94 | }
95 |
96 | componentWillUnmount () {
97 | window.removeEventListener('keyup')
98 | window.removeEventListener('keydown')
99 | }
100 |
101 | getFileName () {
102 | if (!this.state.files.length) return ''
103 |
104 | const filtered = filterImages(this.state.files).map(f => f.path)
105 | if (this.state.operation === MERGE) {
106 | if (this.state.inputValue) return S(this.state.inputValue).ensureRight(`.${this.state.outputType}`).s
107 | return createOutputFileName(this.state.outputType)(filtered)
108 | }
109 | const [first] = filtered
110 | const file = first.split('/').pop()
111 | const name = file.split('.').shift()
112 | return `${name}.${this.state.outputType}`
113 | }
114 |
115 | getIconObject () {
116 | switch (this.state.status) {
117 | case FAILED: return
118 | case DONE: return
119 | case CONVERTING: return
120 | case STAGING: return (
121 | this.setState(state => {
128 | /**
129 | * Copied from Staging.jsx render()
130 | * TODO refactor this check
131 | */
132 | if (!state.files) return;
133 |
134 | const visibleFileTypes = Object.keys(fileTypes)
135 | .reduce((visibleTypes, action) =>
136 | // grab output types from supported types
137 | Object.assign({ [action]: fileTypes[action].output }, visibleTypes), {})
138 |
139 | const notSameExt = currExt =>
140 | !getUniqueExtensions(state.files).includes(currExt.toUpperCase())
141 | visibleFileTypes[CONVERT] = visibleFileTypes[CONVERT].filter(notSameExt)
142 |
143 | return {
144 | operation: op,
145 | outputType: visibleFileTypes[op][0]
146 | }
147 | })}
148 | onConvertClick={() => { this.convert() }}
149 | onSortEnd={({ oldIndex, newIndex }) => {
150 | this.setState({
151 | files: arrayMove(this.state.files, oldIndex, newIndex)
152 | })
153 | }}
154 | onFileClick={(key) => {
155 | this.setState({
156 | files: this.state.files.filter(file => key !== file.path)
157 | }, () => {
158 | this.setState({
159 | status: this.state.files.length ? this.state.status : IDLE
160 | })
161 | })
162 | }}
163 | inputValue={this.state.inputValue
164 | ? S(this.state.inputValue).ensureRight(`.${this.state.outputType}`).s
165 | : ''
166 | }
167 | onChange={(e) => {
168 | if (!e.target.value) return this.setState({ inputValue: null })
169 | const letters = e.target.value.split('')
170 | const New = letters.pop()
171 | return this.setState({
172 | inputValue: S(letters.join('')).chompRight(`.${this.state.outputType}`).s + New
173 | })
174 | }}
175 | />
176 | )
177 | default: return
178 | }
179 | }
180 |
181 | handleOutputTypeChange (e) {
182 | this.setState({ outputType: e.target.value })
183 | }
184 |
185 | convert () {
186 | const filtered = filterImages(this.state.files)
187 |
188 | if (filtered.length) {
189 | this.setState({
190 | status: CONVERTING
191 | })
192 |
193 | const path = filtered[0].path.slice(0, filtered[0].path.length - filtered[0].name.length)
194 | const command = this.state.operation === MERGE ? merge : convert
195 | const fileName = this.getFileName()
196 | const outputPath = this.state.operation === MERGE
197 | ? path + fileName
198 | : path
199 |
200 | command({
201 | files: filtered.map(f => f.path),
202 | outputPath,
203 | outputType: this.state.outputType
204 | }).then(() => {
205 | this.setState({
206 | status: DONE,
207 | fileName: this.state.operation === MERGE ? fileName : `File${filtered.length > 1 ? 's' : ''}`
208 | })
209 |
210 | if (document.hidden) {
211 | notify({ didSucceed: true, operation: this.state.operation})
212 | }
213 |
214 | setTimeout(() => {
215 | this.setState(DEFAULT_STATE)
216 | }, 3000)
217 | }).catch((err) => {
218 | // eslint-disable-next-line no-alert
219 | alert(`ERR: ${err}`)
220 | this.setState({
221 | status: FAILED
222 | })
223 |
224 | if (document.hidden) {
225 | notify({ didSucceed: false, operation: this.state.operation})
226 | }
227 |
228 | setTimeout(() => {
229 | this.setState(DEFAULT_STATE)
230 | })
231 | })
232 | } else this.setState({ status: IDLE })
233 | }
234 |
235 | isHover () {
236 | return this.props.isOver && this.state.status !== CONVERTING
237 | }
238 |
239 | render () {
240 | const { connectDropTarget } = this.props
241 | return connectDropTarget(
242 |
247 | {this.getIconObject()}
248 |
249 |
250 | )
251 | }
252 | }
253 |
254 | // { drop } since other functions can be passed here
255 | export default DropTarget(NativeTypes.FILE, { drop }, collect)(Converter)
256 |
--------------------------------------------------------------------------------
/src/components/FileSorter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Cancel from './svg/Cancel'
3 |
4 | const { SortableContainer, SortableElement, SortableHandle } = require('react-sortable-hoc')
5 |
6 | const DragHandle = SortableHandle(() =>
)
7 |
8 | const Item = SortableElement(({ value, onClose }) => (
9 |
10 |
11 |
{value}
12 |
13 |
14 |
15 |
16 | ))
17 |
18 | const Container = SortableContainer(({ files, onClick }) => (
19 |
20 | {
21 | files.map((file, index) => (
22 | - {
26 | onClick(file.path)
27 | }}
28 | index={index}
29 | />
30 | ))
31 | }
32 |
33 | ))
34 |
35 | const FileSorter = ({ files, onClick, onSortEnd }) => (
36 |
45 | )
46 |
47 | export default FileSorter
48 |
--------------------------------------------------------------------------------
/src/components/Message.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Card = ({ title, detail }) => (
4 |
5 |
{title}
6 |
{detail}
7 |
8 | )
9 |
10 | const Message = ({ hover, state }) => {
11 | switch (state.status) {
12 | case 'IDLE': return (
13 |
17 | )
18 | case 'CONVERTING': return (
19 |
20 | )
21 | case 'DONE': return (
22 |
23 | )
24 | case 'FAILED': return (
25 |
26 | )
27 | default: return null
28 | }
29 | }
30 |
31 | export default Message
32 |
--------------------------------------------------------------------------------
/src/components/Staging.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import ArrowDown from './svg/ArrowDown'
4 | import Merge from './svg/Merge'
5 | import Convert from './svg/Convert'
6 | import Settings from './svg/Settings'
7 | import FileSorter from './FileSorter'
8 | import settingsMenu from '../helpers/menu'
9 |
10 | import { centerEllipsis, getUniqueExtensions } from '../helpers/util'
11 | import {
12 | fileTypes,
13 | CONVERT,
14 | MERGE
15 | } from '../helpers/constants'
16 |
17 | const mapOperationToComp = key => ({
18 | [CONVERT]: ,
19 | [MERGE]:
20 | }[key])
21 |
22 | class Staging extends React.Component {
23 | constructor(props) {
24 | super(props)
25 | this.state = {
26 | inputValue: ''
27 | }
28 | }
29 |
30 | render () {
31 | const {
32 | files,
33 | operation,
34 | outputType,
35 | inputPlaceholder,
36 | handleOutputTypeChange,
37 | onOperationChange,
38 | onConvertClick,
39 | onFileClick,
40 | onSortEnd,
41 | onChange,
42 | inputValue,
43 | } = this.props
44 |
45 | // Remove file types for current file, only works for one file
46 | const visibleFileTypes = Object.keys(fileTypes)
47 | .reduce((visibleTypes, action) =>
48 | // grab output types from supported types
49 | Object.assign({ [action]: fileTypes[action].output }, visibleTypes), {})
50 |
51 | const notSameExt = currExt => !getUniqueExtensions(files).includes(currExt.toUpperCase())
52 | visibleFileTypes[CONVERT] = visibleFileTypes[CONVERT].filter(notSameExt)
53 |
54 | return (
55 |
56 |
settingsMenu.popup()}>
57 |
58 |
59 |
FILENAME
60 |
68 |
ACTION
69 |
70 |
71 | {
72 | Object.keys(fileTypes).map(op => (
73 |
onOperationChange(op)}
77 | >
78 | {mapOperationToComp(op)}
79 | {`${op.charAt(0)}${op.slice(1).toLowerCase()}`}
80 |
81 | ))
82 | }
83 |
84 |
85 |
86 | {
87 | visibleFileTypes[operation].map(type => (
88 | {type.toUpperCase()}
89 | ))
90 | }
91 |
92 |
93 |
94 |
95 |
FILES
96 |
101 |
{operation}
102 |
103 | )
104 | }
105 | }
106 |
107 | export default Staging
108 |
--------------------------------------------------------------------------------
/src/components/Tray.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Converter from './Converter'
3 |
4 | const style = {
5 | width: '100vw',
6 | height: '100vh',
7 | display: 'flex'
8 | }
9 |
10 | const Tray = () => {
11 | return (
12 |
13 |
14 |
15 | )
16 | }
17 |
18 | export default Tray
19 |
--------------------------------------------------------------------------------
/src/components/svg/ArrowDown.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const ArrowDown = () => (
4 |
5 |
6 |
7 | )
8 |
9 | export default ArrowDown
10 |
--------------------------------------------------------------------------------
/src/components/svg/Cancel.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Cancel = () => (
4 |
5 |
6 |
7 | )
8 |
9 | export default Cancel
10 |
--------------------------------------------------------------------------------
/src/components/svg/Convert.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Convert = () => (
4 |
5 |
6 |
7 |
8 |
9 |
10 | )
11 |
12 | export default Convert
13 |
--------------------------------------------------------------------------------
/src/components/svg/Converting.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Converting = () => (
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | )
22 |
23 | export default Converting
24 |
--------------------------------------------------------------------------------
/src/components/svg/Done.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Done = () => (
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | )
29 |
30 | export default Done
31 |
--------------------------------------------------------------------------------
/src/components/svg/Failed.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Failed = () => (
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 |
18 | export default Failed
19 |
--------------------------------------------------------------------------------
/src/components/svg/Idle.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Idle = () => (
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | )
30 |
31 | export default Idle
32 |
--------------------------------------------------------------------------------
/src/components/svg/Merge.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Merge = () => (
4 |
5 |
6 |
7 | )
8 |
9 | export default Merge
10 |
--------------------------------------------------------------------------------
/src/components/svg/Settings.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Settings = () => (
4 |
5 |
6 |
7 | )
8 |
9 | export default Settings
10 |
--------------------------------------------------------------------------------
/src/helpers/configure.js:
--------------------------------------------------------------------------------
1 | const { globalShortcut } = require('electron')
2 |
3 | module.exports = function(mb) {
4 | globalShortcut.register('CommandOrControl+Shift+8', () => {
5 | if (mb.window && mb.window.isVisible()) mb.hideWindow()
6 | else mb.showWindow()
7 | })
8 | }
9 |
--------------------------------------------------------------------------------
/src/helpers/constants.js:
--------------------------------------------------------------------------------
1 | const blue = 'rgba(130, 221, 240, 1)'
2 | const lightBlue = '#bae5fe'
3 | const offwhite = '#fefeff'
4 | const red = 'rgb(209, 75, 75)'
5 | const black = 'rgba(0,0,0,1)'
6 | const transBlack = 'rgba(0,0,0, 0.25)'
7 | const green = 'rgb(132, 255, 144)'
8 | const grey = '#F0F0EC'
9 | const orange = '#ee9a4c'
10 | const lightOrange = '#efb47c'
11 |
12 | const MERGE = 'MERGE'
13 | const CONVERT = 'CONVERT'
14 | module.exports = {
15 | STAGING: 'STAGING',
16 | IDLE: 'IDLE',
17 | CONVERTING: 'CONVERTING',
18 | DONE: 'DONE',
19 | FAILED: 'FAILED',
20 | MERGE,
21 | CONVERT,
22 | fileTypes: {
23 | [MERGE]: {
24 | input: ['png', 'jpg', 'gif'],
25 | output: ['pdf']
26 | },
27 | [CONVERT]: {
28 | input: ['png', 'jpg', 'gif', 'bmp', 'tiff', 'webp'],
29 | output: ['png', 'jpg', 'gif', 'bmp', 'tiff', 'webp']
30 | }
31 | },
32 | blue,
33 | lightBlue,
34 | offwhite,
35 | red,
36 | black,
37 | transBlack,
38 | grey,
39 | green,
40 | orange,
41 | lightOrange
42 | }
43 |
--------------------------------------------------------------------------------
/src/helpers/functional.js:
--------------------------------------------------------------------------------
1 | const pluck = key => obj => obj[key]
2 |
3 | const map = f => mappable => mappable.map(f)
4 |
5 | const compose = (...fns) => res => fns.reduce((accum, next) => next(accum), res)
6 |
7 | module.exports = {
8 | pluck,
9 | map,
10 | compose
11 | }
12 |
--------------------------------------------------------------------------------
/src/helpers/menu.js:
--------------------------------------------------------------------------------
1 | const { remote, shell } = require('electron')
2 |
3 | const { app, Menu } = remote
4 |
5 | const toggleOpenAtLogin = menuItem =>
6 | app.setLoginItemSettings({ openAtLogin: menuItem.checked, openAsHidden: true })
7 |
8 | const configureSettingsMenu = () => {
9 | return Menu.buildFromTemplate([
10 | {
11 | label: `Alchemy ${app.getVersion()}`,
12 | enabled: false
13 | },
14 | { type: 'separator' },
15 | {
16 | label: 'Start at Login',
17 | type: 'checkbox',
18 | checked: app.getLoginItemSettings().openAtLogin,
19 | click: toggleOpenAtLogin
20 | },
21 | { type: 'separator' },
22 | {
23 | label: 'Check for updates...',
24 | click() {
25 | shell.openExternal('https://github.com/dawnlabs/alchemy/releases')
26 | }
27 | },
28 | {
29 | label: 'Submit Feedback...',
30 | click() {
31 | shell.openExternal('mailto:hi@dawnlabs.io?subject=Alchemy Feedback&body=')
32 | }
33 | },
34 | { type: 'separator' },
35 | { label: 'Quit Alchemy', role: 'quit' }
36 | ])
37 | }
38 |
39 | module.exports = configureSettingsMenu()
40 |
--------------------------------------------------------------------------------
/src/helpers/notifier/index.js:
--------------------------------------------------------------------------------
1 | /* global Notification */
2 | const invariant = require('invariant')
3 | const { MERGE, CONVERT } = require('../constants')
4 | const text = require('./text.json')
5 |
6 | let imgPath
7 |
8 | module.exports = {
9 | init,
10 | notify
11 | }
12 |
13 | function init (appPath) {
14 | imgPath = `${appPath}/img`
15 | }
16 |
17 | function notify ({ didSucceed, operation }) {
18 | invariant([MERGE, CONVERT].includes(operation), 'Invalid notification operation')
19 |
20 | const { title, body, icon } = genInfo(didSucceed, operation)
21 |
22 | const n = new Notification(title, {
23 | body,
24 | icon
25 | })
26 |
27 | return n
28 | }
29 |
30 | function genInfo (didSucceed, operation) {
31 | const operationText = operation === MERGE ? 'Merge' : 'Conversion'
32 |
33 | if (didSucceed) {
34 | return {
35 | title: `${operationText} complete!`,
36 | body: getRandomElement(text.success),
37 | icon: `${imgPath}/success.png`
38 | }
39 | }
40 |
41 | return {
42 | title: `Oh no! ${operationText} failed.`,
43 | body: getRandomElement(text.failure),
44 | icon: `${imgPath}/failure.png`
45 | }
46 | }
47 |
48 | function getRandomElement (array) {
49 | const rnd = Math.random()
50 | return array[Math.floor(rnd * array.length)]
51 | }
52 |
--------------------------------------------------------------------------------
/src/helpers/notifier/text.json:
--------------------------------------------------------------------------------
1 | {
2 | "success": [
3 | "Your images are done a'brewin.",
4 | "The transmutation was a success.",
5 | "LVL 100 Alchemy achieved.",
6 | "Good as gold.",
7 | "A magical success! Your files are done."
8 | ],
9 | "failure": [
10 | "The moons were not aligned.",
11 | "There be dark magic afoot.",
12 | "Philosopher's stone sold separately.",
13 | "Another deception of the photo sorcerer.",
14 | "The night is dark and full of terrors."
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/src/helpers/util.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { pluck, compose, map } = require('./functional')
3 | const { fileTypes } = require('./constants')
4 |
5 | const replaceSpaceCharacters = str =>
6 | str.replace(/\s/g, '\\ ')
7 | .replace(/'/g, '\\\'')
8 |
9 | const concatFiles = files =>
10 | files.map(path => path.split('/').pop())
11 | .map(file => path.basename(file, path.extname(file)))
12 | .join('_')
13 | .substr(0, 50)
14 |
15 | const filterImages = files => files.filter(file => file.type.includes('image'))
16 |
17 | const createOutputFileName = outputType =>
18 | files => `ALCHEMY-${concatFiles(files)}.${outputType || 'pdf'}`
19 |
20 | function centerEllipsis(str, length = 7) {
21 | return (str.length > (length * 2) + 1)
22 | ? `${str.substr(0, length)}...${str.substr(str.length - length, str.length)}`
23 | : str
24 | }
25 |
26 | const displayOutputFileName = outputType =>
27 | compose(
28 | filterImages,
29 | map(pluck('path')),
30 | createOutputFileName(outputType),
31 | centerEllipsis
32 | )
33 |
34 | const inputTypeSet = Object.keys(fileTypes)
35 | .reduce((types, action) =>
36 | new Set([...types, ...fileTypes[action].input]),
37 | new Set()) // start with empty Set
38 |
39 | const isValidFileType = fileType => inputTypeSet.has(fileType)
40 |
41 | const getFileExtension = fileName => fileName.split('.').pop()
42 |
43 | const uniqueFiles = (files, newArray) =>
44 | files.concat(
45 | newArray.filter(file =>
46 | !files.map(file => file.path).includes(file.path)))
47 |
48 | const getUniqueExtensions = files => {
49 | const normalizeExtension = name => {
50 | const ext = name
51 | .split(".")
52 | .pop()
53 | .toUpperCase();
54 |
55 | return ext === "JPEG" ? "JPG" : ext;
56 | }
57 |
58 | return [
59 | ...new Set(files.map(({ name }) => normalizeExtension(name)))
60 | ]
61 | }
62 |
63 | module.exports = {
64 | displayOutputFileName,
65 | centerEllipsis,
66 | filterImages,
67 | createOutputFileName,
68 | concatFiles,
69 | replaceSpaceCharacters,
70 | uniqueFiles,
71 | isValidFileType,
72 | getFileExtension,
73 | getUniqueExtensions
74 | }
75 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import 'babel-core/register'
2 | import 'babel-polyfill'
3 |
4 | import React from 'react'
5 | import { render } from 'react-dom'
6 | import HTML5Backend from 'react-dnd-html5-backend'
7 | import { DragDropContext } from 'react-dnd'
8 | import { ipcRenderer } from 'electron'
9 |
10 | import Tray from './components/Tray'
11 | import api from './api'
12 | import notifier from './helpers/notifier'
13 |
14 | require('../styles/index.scss')
15 |
16 | // initialize api
17 | ipcRenderer.send('APP_PATH_REQUEST')
18 |
19 | ipcRenderer.on('APP_PATH_REPLY', (event, arg) => {
20 | api.init(arg)
21 | notifier.init(arg)
22 | })
23 | const App = DragDropContext(HTML5Backend)(Tray)
24 |
25 | render(
26 | ,
27 | document.getElementById('root')
28 | )
29 |
--------------------------------------------------------------------------------
/styles/color.scss:
--------------------------------------------------------------------------------
1 | $grey: #B8B7B2;
2 | $lightGrey: #F0F0EC;
3 | $blue: #3B86FF;
4 | $black: #333;
5 | $green: #4DB15E;
6 | $purple: #B43BFF;
7 | $orange: #ffb43b;
8 | $red: #ff523b;
9 |
--------------------------------------------------------------------------------
/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import 'color';
2 | @import 'keyframe';
3 |
4 | * {
5 | box-sizing: border-box;
6 | user-select: none;
7 | }
8 |
9 | html {
10 | font-family: '-apple-system', 'San Francisco', BlinkMacSystemFont, 'Helvetica Neue', Helvetica, sans-serif;
11 | color: $black;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | overflow: hidden;
15 | /* Prevent background from becoming transparent */
16 | background: white;
17 | }
18 |
19 | h1,
20 | h2,
21 | h3,
22 | h4,
23 | h5,
24 | h6 {
25 | font-size: 1.563rem;
26 | font-weight: normal;
27 | letter-spacing: -0.6px;
28 | text-align: center;
29 | margin: 0;
30 | }
31 |
32 | p {
33 | letter-spacing: -0.2px;
34 | margin-bottom: 0;
35 | }
36 |
37 | .detail {
38 | font-size: 0.8rem;
39 | color: $grey;
40 | }
41 |
42 | .container {
43 | display: flex;
44 | flex-direction: column;
45 | justify-content: space-around;
46 | align-items: center;
47 | margin: 16px;
48 | padding: 40px 16px;
49 | height: calc(100% - 32px);
50 | width: calc(100% - 32px);
51 |
52 | &::after {
53 | content: '';
54 | position: absolute;
55 | border-radius: 8px;
56 | height: calc(100% - 38px);
57 | width: calc(100% - 38px);
58 | top: 16px;
59 | }
60 | }
61 |
62 | .no-padding {
63 | padding: 0;
64 | }
65 |
66 | .converting-arrow {
67 | animation: convertingArrow 0.5s infinite alternate;
68 | }
69 |
70 | .border-hover::after,
71 | .border-dashed::after {
72 | border: 3px dashed $lightGrey;
73 | z-index: -999;
74 | }
75 |
76 | .border-hover::after {
77 | animation: pulse 0.75s infinite alternate;
78 | border-style: solid;
79 | }
80 |
81 | .border-dashed::after {
82 | border-style: dashed;
83 | }
84 |
85 | .border-hover .file1 {
86 | animation: file1 0.25s forwards;
87 | }
88 |
89 | .border-hover .file2 {
90 | animation: file2 0.25s forwards;
91 | }
92 |
93 | .border-hover .file3 {
94 | animation: file3 0.25s forwards;
95 | }
96 |
97 | .staging {
98 | padding: 0 10px;
99 | z-index: 999;
100 | display: flex;
101 | flex-direction: column;
102 | justify-content: flex-start;
103 | align-items: stretch;
104 | width: 100%;
105 | height: 100%;
106 |
107 | label {
108 | font-size: 10.2px;
109 | margin-top: 12px;
110 | margin-bottom: 4px;
111 | font-weight: bold;
112 | color: rgba(0, 0, 0, 0.3);
113 |
114 | &:first-of-type {
115 | margin-top: 0;
116 | }
117 | }
118 |
119 | input {
120 | border: 1px solid rgba(255, 255, 255, 0);
121 | border-radius: 2px;
122 | background: rgba(0, 0, 0, 0.1);
123 | font-size: 1.563rem;
124 | text-align: center;
125 | color: #333;
126 | box-shadow: none;
127 | font-weight: normal;
128 | height: 46px;
129 | padding: 8px 16px;
130 | transition: .2s background, .2s box-shadow;
131 |
132 | &:focus {
133 | outline: none;
134 | box-shadow: none;
135 | border: 1px solid rgba(255, 255, 255, 0);
136 | background: rgba(0, 0, 0, 0.2);
137 | }
138 | }
139 |
140 | &-item {
141 | margin: 6px 0 0;
142 | padding: 4px 8px;
143 | border-radius: 3px;
144 |
145 | &:nth-child(2n) {
146 | background: $lightGrey;
147 | }
148 | &:nth-child(2n+1) {
149 | background: lighten($lightGrey, 5%);
150 | }
151 | }
152 | }
153 |
154 | .row {
155 | display: flex;
156 | align-items: center;
157 | }
158 |
159 | .switch {
160 | background: rgba(0, 0, 0, 0.2);
161 | margin: 8px;
162 | margin-left: 0;
163 | border: 1px #D8D8D8 solid;
164 | height: 42px;
165 | display: flex;
166 | border-radius: 3px;
167 | width: 96px;
168 | }
169 |
170 | .switch__btn {
171 | border: 0;
172 | cursor: pointer;
173 | outline: none;
174 | opacity: 0.6;
175 | filter: grayscale(100%);
176 | display: inline-flex;
177 | flex-direction: column;
178 | align-items: center;
179 | justify-content: center;
180 | height: 40.5px;
181 | width: 100%;
182 | font-size: 0.512rem;
183 | padding-top: 4px;
184 |
185 | svg {
186 | margin-bottom: 3px;
187 | }
188 | }
189 |
190 | .merge {
191 | border-radius: 2px 0 0 2px;
192 | }
193 |
194 | .convert {
195 | border-radius: 0 2px 2px 0;
196 | }
197 |
198 | .switch__btn-active {
199 | opacity: 1;
200 | background: #fff;
201 | filter: grayscale(0%);
202 | }
203 |
204 | .dropdown {
205 | position: relative;
206 | border: 1px #D8D8D8 solid;
207 | height: 42px;
208 | width: 156px;
209 | border-radius: 3px;
210 | cursor: pointer;
211 |
212 | select {
213 | background: rgba(0, 0, 0, 0) !important;
214 | width: 100%;
215 | border: 0;
216 | outline: none;
217 | padding: 14px 16px;
218 | -webkit-appearance: none;
219 | cursor: pointer;
220 | }
221 | }
222 |
223 | .arrow-down {
224 | position: absolute;
225 | top: 19px;
226 | right: 20px;
227 | }
228 |
229 | .button__convert {
230 | cursor: pointer;
231 | background: $green;
232 | color: white;
233 | font-weight: bold;
234 | text-align: center;
235 | letter-spacing: 1px;
236 | border: none;
237 | border-radius: 3px;
238 | padding: 16px 24px;
239 |
240 | &:focus, &:hover, &:active {
241 | outline: none;
242 | }
243 |
244 | &:hover {
245 | background: darken($green, 5%)
246 | }
247 | }
248 |
249 | .button__settings {
250 | border: none;
251 | cursor: pointer;
252 | background: none;
253 | padding: 0px;
254 | width: 16px;
255 | margin: -4px -12px 0 auto;
256 | min-height: 16px;
257 |
258 | &:focus, &:hover, &:active {
259 | outline: none;
260 | }
261 | }
262 |
263 | .file-list {
264 | background: rgba(0, 0, 0, 0.1);
265 | padding: 4px;
266 | height: 106px;
267 | margin-bottom: 8px;
268 | overflow-y: scroll;
269 | }
270 |
271 | .file-list__item {
272 | justify-content: space-between;
273 | align-items: center;
274 | display: flex;
275 | background: #fff;
276 | background-color: #fff;
277 | padding: 8px 12px;
278 | font-size: 1rem;
279 | border-radius: 2px;
280 | margin-bottom: 4px;
281 | z-index: 999;
282 |
283 | &:last-child {
284 | margin-bottom: 0;
285 | }
286 |
287 | .file-name {
288 | cursor: default;
289 | width: 164px;
290 | overflow: hidden;
291 | text-overflow: ellipsis;
292 | white-space: nowrap;
293 | }
294 |
295 | button {
296 | display: flex;
297 | padding: 0;
298 | cursor: pointer;
299 | border: none;
300 | background: none;
301 |
302 | &:focus, &:hover, &:active {
303 | outline: none;
304 | }
305 | }
306 | }
307 |
308 | .drag-handle {
309 | display: block;
310 | width: 16px;
311 | height: 16px;
312 | background-image: url('data:image/svg+xml;charset=utf-8, ');
313 | background-size: contain;
314 | background-repeat: no-repeat;
315 | opacity: .25;
316 | cursor: row-resize;
317 | }
318 |
--------------------------------------------------------------------------------
/styles/keyframe.scss:
--------------------------------------------------------------------------------
1 | @keyframes file1 {
2 | from {
3 | transform: translate(0, 0);
4 | }
5 |
6 | to {
7 | transform: translate(-22px, 4px);
8 | }
9 | }
10 |
11 | @keyframes file2 {
12 | from {
13 | transform: translate(0, 0);
14 | }
15 |
16 | to {
17 | transform: translate(0, -12px);
18 | }
19 | }
20 |
21 | @keyframes file3 {
22 | from {
23 | transform: translate(0, 0);
24 | }
25 |
26 | to {
27 | transform: translate(22px, 4px);
28 | }
29 | }
30 |
31 | @keyframes convertingArrow {
32 | from {
33 | transform: translate(-3px, 0);
34 | }
35 |
36 | to {
37 | transform: translate(3px, 0);
38 | }
39 | }
40 |
41 | @keyframes pulse {
42 | 0% {
43 | border-color: $lightGrey;
44 | }
45 |
46 | 100% {
47 | border-color: $grey;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/test/util.test.js:
--------------------------------------------------------------------------------
1 | /* global describe, it */
2 | /* eslint-disable prefer-arrow-callback */
3 |
4 | const { expect } = require('chai')
5 | const { replaceSpaceCharacters, getUniqueExtensions } = require('../src/helpers/util')
6 |
7 | describe('UTIL', function () {
8 | describe('replaceSpaceCharacters()', function () {
9 | it('replace space characters with escaped space', () => {
10 | const fileName = "This is a bad's file name.png"
11 | expect(replaceSpaceCharacters(fileName)).to.equal("This\\ is\\ a\\ bad\\'s\\ file\\ name.png")
12 | })
13 | })
14 |
15 | describe('getUniqueExtensions', () => {
16 | it('should return no duplicate file extensions', () => {
17 | const files = [
18 | { name:'file-name_1.png' },
19 | { name:'file-name_2.jpg' },
20 | { name:'file-name_3.png' },
21 | { name:'file-name_4.jpg' },
22 | { name:'file-name_5.JPG' },
23 | { name:'file-name_6.JPEG' },
24 | { name:'file-name_7.JPEG' },
25 | { name:'file-name_7.gif' },
26 | ]
27 |
28 | expect(getUniqueExtensions(files)).to.deep.equal(['PNG', 'JPG', 'GIF']);
29 | })
30 | })
31 | })
32 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const ExtractTextPlugin = require('extract-text-webpack-plugin')
3 |
4 | const extractCSS = new ExtractTextPlugin('public/[name].css')
5 |
6 | module.exports = {
7 | resolve: {
8 | extensions: ['.js', '.jsx', '.json']
9 | },
10 |
11 | devtool: 'source-map',
12 |
13 | target: 'electron-renderer',
14 |
15 | entry: './src/index.jsx',
16 |
17 | output: {
18 | path: `${__dirname}/public`,
19 | filename: '[name].js',
20 | chunkFilename: '[id].chunk.js'
21 | },
22 |
23 | module: {
24 | rules: [
25 | {
26 | test: /\.jsx?$/,
27 | exclude: /node_modules/,
28 | use: {
29 | loader: 'babel-loader',
30 | options: {
31 | presets: ['@babel/preset-react', '@babel/preset-env']
32 | }
33 | }
34 | },
35 | {
36 | test: /\.html$/,
37 | use: 'html'
38 | },
39 | {
40 | test: /\.(png|jpe?g|gif|svg|woff|woff2|ttf|eot|ico)$/,
41 | use: 'file?name=assets/[name].[hash].[ext]'
42 | },
43 | {
44 | test: /\.scss$/,
45 | use: [
46 | 'style-loader', // creates style nodes from JS strings
47 | 'css-loader', // translates CSS into CommonJS
48 | 'sass-loader' // compiles Sass to CSS
49 | ]
50 | }
51 | ]
52 | },
53 |
54 | plugins: [
55 | // new webpack.optimize.CommonsChunkPlugin('scripts/common.js'),
56 | new webpack.DefinePlugin({
57 | 'process.env.PATH': JSON.stringify(process.env.PATH)
58 | }),
59 | extractCSS
60 | ]
61 | }
62 |
--------------------------------------------------------------------------------