├── .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 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fdawnlabs%2Falchemy.svg?type=shield)](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 | []()![demo](https://cloud.githubusercontent.com/assets/10369094/24595824/7e6f7f74-17ff-11e7-80dd-b2602b9f0ba1.gif) 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 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fdawnlabs%2Falchemy.svg?type=large)](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 | 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 | 59 | 60 | 68 | 69 |
70 |
71 | { 72 | Object.keys(fileTypes).map(op => ( 73 | 81 | )) 82 | } 83 |
84 |
85 | 92 | 93 |
94 |
95 | 96 | 101 | 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 | --------------------------------------------------------------------------------