├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build └── icons │ ├── 256x256.png │ ├── icon.icns │ └── icon.ico ├── commitlint.config.js ├── demo.gif ├── logo.png ├── package-lock.json ├── package.json ├── src ├── config │ ├── electron-builder.ts │ └── vite.ts ├── main │ ├── image-compressor.ts │ ├── index.ts │ ├── menu.ts │ ├── preload.ts │ ├── store │ │ ├── index.ts │ │ └── module │ │ │ └── app.ts │ ├── types │ │ ├── cwebp-bin.d.ts │ │ ├── index.d.ts │ │ └── vite.shim.d.ts │ ├── update-check.ts │ └── utils │ │ └── index.ts ├── renderer │ ├── App.vue │ ├── assets │ │ ├── styles │ │ │ └── base.css │ │ └── svg │ │ │ ├── arrow-circle-up.svg │ │ │ ├── cog.svg │ │ │ ├── logo.svg │ │ │ └── times.svg │ ├── components │ │ ├── AppDragArea.vue │ │ ├── AppFileList.vue │ │ ├── AppFooter.vue │ │ ├── AppSettingRow.vue │ │ └── ui │ │ │ ├── AppInput.vue │ │ │ ├── AppPreloader.vue │ │ │ └── AppToggle.vue │ ├── electron.ts │ ├── env.d.ts │ ├── index.html │ ├── main.ts │ ├── router.ts │ ├── store │ │ └── index.ts │ ├── types │ │ └── index.d.ts │ └── views │ │ ├── Main.vue │ │ └── Settings.vue └── scripts │ ├── build-electron.ts │ ├── build-vue.ts │ ├── build.ts │ └── dev-server.ts ├── tsconfig.electron.json ├── tsconfig.json ├── yarn-error.log └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | build -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | parser: 'vue-eslint-parser', 7 | parserOptions: { 8 | parser: '@typescript-eslint/parser' 9 | }, 10 | extends: [ 11 | '@vue/eslint-config-standard', 12 | 'plugin:vue/vue3-recommended', 13 | 'plugin:@typescript-eslint/recommended' 14 | ], 15 | plugins: ['@typescript-eslint', 'prettier'], 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 19 | 'arrow-parens': ['error', 'as-needed'], 20 | 'no-unused-vars': 'off', 21 | 'no-undef': 'off', 22 | indent: 'off', 23 | 'vue/component-name-in-template-casing': [ 24 | 'error', 25 | 'PascalCase', 26 | { registeredComponentsOnly: false } 27 | ], 28 | 'vue/no-v-html': 'off', 29 | 'vue/require-prop-types': 'off', 30 | 'vue/require-default-prop': 'off', 31 | 'vue/multi-word-component-names': 'off', 32 | '@typescript-eslint/semi': ['error', 'never'], 33 | '@typescript-eslint/member-delimiter-style': [ 34 | 'error', 35 | { multiline: { delimiter: 'none' } } 36 | ], 37 | '@typescript-eslint/type-annotation-spacing': ['error', {}], 38 | '@typescript-eslint/consistent-type-imports': [ 39 | 'error', 40 | { prefer: 'type-imports', disallowTypeAnnotations: false } 41 | ], 42 | '@typescript-eslint/camelcase': 'off', 43 | '@typescript-eslint/explicit-function-return-type': 'off', 44 | '@typescript-eslint/explicit-member-accessibility': 'off', 45 | '@typescript-eslint/no-explicit-any': 'off', 46 | '@typescript-eslint/no-parameter-properties': 'off', 47 | '@typescript-eslint/no-empty-interface': 'off', 48 | '@typescript-eslint/ban-ts-ignore': 'off', 49 | '@typescript-eslint/no-empty-function': 'off', 50 | '@typescript-eslint/no-non-null-assertion': 'off', 51 | '@typescript-eslint/ban-ts-comment': 'off', 52 | '@typescript-eslint/explicit-module-boundary-types': 'off', 53 | '@typescript-eslint/ban-types': 'off', 54 | '@typescript-eslint/no-namespace': 'off', 55 | '@typescript-eslint/indent': ['error', 2] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: #['antonreshetov'] 4 | patreon: antonreshetov 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: antonreshetov 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://paypal.me/antonreshetov'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. macOS Monterey 12.1] 25 | - App Version [e.g. 1.3.2] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build/* 4 | !build/icons 5 | src/renderer/types/components.d.ts 6 | 7 | .vscode 8 | .idea 9 | .DS_Store 10 | # Snyk 11 | .dccache 12 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.3.2](https://github.com/antonreshetov/image-optimizer/compare/v1.3.1...v1.3.2) (2022-01-17) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * jpeg compression in place [#12](https://github.com/antonreshetov/image-optimizer/issues/12) ([#13](https://github.com/antonreshetov/image-optimizer/issues/13)) ([7d1fd7b](https://github.com/antonreshetov/image-optimizer/commit/7d1fd7b913aaccc3a902510f561bfd9a14ecc050)) 7 | 8 | 9 | 10 | ## [1.3.1](https://github.com/antonreshetov/image-optimizer/compare/v1.3.0...v1.3.1) (2022-01-11) 11 | 12 | 13 | ### Features 14 | 15 | * add file associations to app ([#11](https://github.com/antonreshetov/image-optimizer/issues/11)) ([3ae9516](https://github.com/antonreshetov/image-optimizer/commit/3ae95168f4d097727c5437df9cea87334125c52e)) 16 | 17 | 18 | 19 | # [1.3.0](https://github.com/antonreshetov/image-optimizer/compare/v1.2.0...v1.3.0) (2021-12-04) 20 | 21 | 22 | ### Features 23 | 24 | * **main:** open images from dialog menu ([#7](https://github.com/antonreshetov/image-optimizer/issues/7)) ([e591d35](https://github.com/antonreshetov/image-optimizer/commit/e591d3597e2c58ccbd3790c3911ebd5100d2a05d)) 25 | 26 | 27 | 28 | # [1.2.0](https://github.com/antonreshetov/image-optimizer/compare/v1.1.0...v1.2.0) (2021-12-03) 29 | 30 | 31 | ### Features 32 | 33 | * add animation on completion ([#5](https://github.com/antonreshetov/image-optimizer/issues/5)) ([a63fae3](https://github.com/antonreshetov/image-optimizer/commit/a63fae31846baf3df8bdf94bdbd02c5dc37b57f5)) 34 | * add check for update ([#6](https://github.com/antonreshetov/image-optimizer/issues/6)) ([ccf3b13](https://github.com/antonreshetov/image-optimizer/commit/ccf3b13ff09dae5e3bb7cdcfc69b7b1988947156)) 35 | 36 | 37 | 38 | # [1.1.0](https://github.com/antonreshetov/image-optimizer/compare/v1.0.2...v1.1.0) (2021-11-29) 39 | 40 | 41 | ### Features 42 | 43 | * add gif support ([#3](https://github.com/antonreshetov/image-optimizer/issues/3)) ([e697da3](https://github.com/antonreshetov/image-optimizer/commit/e697da3603344f9064cf062601c81c0872851664)) 44 | 45 | 46 | 47 | ## [1.0.2](https://github.com/antonreshetov/image-optimizer/compare/v1.0.1...v1.0.2) (2021-11-27) 48 | 49 | 50 | ### Bug Fixes 51 | 52 | * **main:** re-init menu after close window ([d35c98b](https://github.com/antonreshetov/image-optimizer/commit/d35c98bee92f4ed1ccf1a3b728fd8086acb71c57)) 53 | 54 | 55 | 56 | ## [1.0.1](https://github.com/antonreshetov/image-optimizer/compare/v1.0.0...v1.0.1) (2021-11-27) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * **main:** set window bounds to store [#1](https://github.com/antonreshetov/image-optimizer/issues/1) ([#2](https://github.com/antonreshetov/image-optimizer/issues/2)) ([d568291](https://github.com/antonreshetov/image-optimizer/commit/d568291a9705f5d2b6bea00bcbf683dd0157a27e)) 62 | 63 | 64 | 65 | # 1.0.0 (2021-11-25) 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anton Reshetov 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 | 4 |

5 |

Image Optimizer

6 |

7 | GitHub package.json version 8 | GitHub All Releases 9 | GitHub 10 |

11 |

12 | Built with Electron, Vue & Vite. 13 |

14 | 15 | A free and open source tool for optimizing images and vector graphics. 16 | 17 |

18 | 19 |

20 | 21 | 22 | ## Core libs 23 | - [mozjpeg](https://github.com/mozilla/mozjpeg) 24 | - [pngquant](https://pngquant.org) 25 | - [cwebp](https://developers.google.com/speed/webp/docs/cwebp) 26 | - [gifsicle](https://www.lcdf.org/gifsicle/) 27 | - [SVGO](https://github.com/svg/svgo) 28 | 29 | ## Download and Installation on macOS 30 | 31 | Go to [Releases](https://github.com/antonreshetov/image-optimizer/releases) get the latest build, download and install. 32 | 33 | ## Development 34 | ```bash 35 | # install dependencies 36 | yarn 37 | # serve with hot reload 38 | yarn dev 39 | ``` 40 | 41 | ## Build 42 | ```bash 43 | # build application for production 44 | yarn build 45 | ``` 46 | 47 | ## Related 48 | - [Electron Vue Vite Boilerplate](https://github.com/antonreshetov/electron-vue-vite-boilerplate) 49 | 50 | Copyright (c) 2021-present, Anton Reshetov. 51 | -------------------------------------------------------------------------------- /build/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonreshetov/image-optimizer/4e02bf4ec2dc65b44bebbce2b0a304c16ad1acb3/build/icons/256x256.png -------------------------------------------------------------------------------- /build/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonreshetov/image-optimizer/4e02bf4ec2dc65b44bebbce2b0a304c16ad1acb3/build/icons/icon.icns -------------------------------------------------------------------------------- /build/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonreshetov/image-optimizer/4e02bf4ec2dc65b44bebbce2b0a304c16ad1acb3/build/icons/icon.ico -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonreshetov/image-optimizer/4e02bf4ec2dc65b44bebbce2b0a304c16ad1acb3/demo.gif -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antonreshetov/image-optimizer/4e02bf4ec2dc65b44bebbce2b0a304c16ad1acb3/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "image-optimizer", 3 | "version": "1.4.0", 4 | "description": "A tool for optimizing images and vector graphics", 5 | "main": "build/src/main/index.js", 6 | "scripts": { 7 | "dev": "tsc-watch -p tsconfig.electron.json --onSuccess 'npm run dev:server'", 8 | "dev:server": "node build/src/scripts/dev-server.js", 9 | "build:ts": "tsc -p tsconfig.electron.json", 10 | "build": "npm run build:ts && node build/src/scripts/build.js ", 11 | "lint": "eslint --ext .js,.ts,.vue . src", 12 | "lint:fix": "eslint --ext .js,.ts,.vue . --fix src", 13 | "release": "bumpp -c 'build: release v' -t", 14 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 15 | "prepare": "husky install" 16 | }, 17 | "repository": "https://github.com/antonreshetov/image-optimizer", 18 | "author": { 19 | "name": "Anton Reshetov", 20 | "url": "https://github.com/antonreshetov" 21 | }, 22 | "lint-staged": { 23 | "*.{js,ts,vue}": [ 24 | "prettier --write", 25 | "eslint --fix" 26 | ] 27 | }, 28 | "dependencies": { 29 | "axios": "^0.24.0", 30 | "canvas-confetti": "^1.4.0", 31 | "cwebp-bin": "6.1.2", 32 | "electron-store": "^8.0.1", 33 | "fs-extra": "^10.0.0", 34 | "gifsicle": "5.3.0", 35 | "junk": "^3.1.0", 36 | "mime-types": "^2.1.34", 37 | "mozjpeg": "^7.0.0", 38 | "pinia": "^2.0.9", 39 | "pngquant-bin": "^6.0.0", 40 | "queue": "^6.0.2", 41 | "svgo": "^2.8.0", 42 | "vue": "^3.2.26", 43 | "vue-router": "^4.0.12" 44 | }, 45 | "devDependencies": { 46 | "@commitlint/cli": "^16.0.1", 47 | "@commitlint/config-conventional": "^16.0.0", 48 | "@types/canvas-confetti": "^1.4.2", 49 | "@types/estree": "^0.0.50", 50 | "@types/gifsicle": "^5.2.0", 51 | "@types/mime-types": "^2.1.1", 52 | "@types/mozjpeg": "^5.0.0", 53 | "@types/node": "^17.0.4", 54 | "@types/pngquant-bin": "^4.0.0", 55 | "@types/svgo": "^2.6.0", 56 | "@types/webpack": "^5.28.0", 57 | "@typescript-eslint/eslint-plugin": "^5.8.0", 58 | "@typescript-eslint/parser": "^5.8.0", 59 | "@vitejs/plugin-vue": "^1.10.2", 60 | "@vue/cli": "^4.5.15", 61 | "@vue/eslint-config-standard": "^6.1.0", 62 | "bumpp": "^7.1.1", 63 | "chalk": "^4.1.2", 64 | "chokidar": "^3.5.2", 65 | "electron": "^16.0.5", 66 | "electron-builder": "^22.14.5", 67 | "electron-devtools-installer": "^3.2.0", 68 | "eslint": "^8.5.0", 69 | "eslint-config-prettier": "^8.3.0", 70 | "eslint-plugin-import": "^2.25.3", 71 | "eslint-plugin-node": "^11.1.0", 72 | "eslint-plugin-prettier": "^4.0.0", 73 | "eslint-plugin-promise": "^6.0.0", 74 | "eslint-plugin-vue": "^8.2.0", 75 | "husky": "^7.0.0", 76 | "lint-staged": "^12.1.4", 77 | "prettier": "^2.5.1", 78 | "sass": "^1.45.1", 79 | "tsc-watch": "^4.6.0", 80 | "typescript": "^4.5.4", 81 | "unplugin-icons": "^0.13.0", 82 | "unplugin-vue-components": "^0.17.11", 83 | "vite": "^2.7.6", 84 | "vue-tsc": "^0.30.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/config/electron-builder.ts: -------------------------------------------------------------------------------- 1 | import type { Configuration } from 'electron-builder' 2 | import path from 'path' 3 | 4 | export default { 5 | productName: 'Image Optimizer', 6 | appId: 'com.antonreshetov.image-optimizer', 7 | directories: { 8 | output: path.resolve(__dirname, '../../../dist') 9 | }, 10 | nsis: { 11 | oneClick: false, 12 | perMachine: false, 13 | allowToChangeInstallationDirectory: true, 14 | shortcutName: 'Image Optimizer' 15 | }, 16 | mac: { 17 | target: [ 18 | { target: 'dmg', arch: 'arm64' }, 19 | { target: 'dmg', arch: 'x64' } 20 | ] 21 | }, 22 | win: { 23 | target: 'nsis' 24 | }, 25 | linux: { 26 | target: ['snap'] 27 | }, 28 | extraMetadata: { 29 | main: 'src/main/index.js' 30 | }, 31 | fileAssociations: [ 32 | { name: 'JPG', ext: 'jpg' }, 33 | { name: 'PNG', ext: 'png' }, 34 | { name: 'GIF', ext: 'gif' }, 35 | { name: 'SVG', ext: 'svg' } 36 | ], 37 | files: [ 38 | '!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}', 39 | '!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}', 40 | '!**/node_modules/*.d.ts', 41 | '!**/node_modules/.bin', 42 | 'package.json', 43 | { 44 | from: 'build/src', 45 | to: 'src', 46 | filter: ['main/**/*.js', 'renderer/**/*'] 47 | } 48 | ] 49 | } as Configuration 50 | -------------------------------------------------------------------------------- /src/config/vite.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import vuePlugin from '@vitejs/plugin-vue' 3 | import { defineConfig } from 'vite' 4 | import Components from 'unplugin-vue-components/vite' 5 | import Icons from 'unplugin-icons/vite' 6 | import { FileSystemIconLoader } from 'unplugin-icons/loaders' 7 | import IconsResolver from 'unplugin-icons/resolver' 8 | 9 | const pathSrc = path.resolve(__dirname, '../../../src/renderer') 10 | const pathOut = path.resolve(__dirname, '../renderer') 11 | 12 | export default defineConfig({ 13 | root: pathSrc, 14 | publicDir: 'public', 15 | server: { 16 | port: 3000, 17 | open: false 18 | }, 19 | build: { 20 | outDir: pathOut, 21 | emptyOutDir: true, 22 | cssCodeSplit: false, 23 | rollupOptions: { 24 | output: { 25 | // Непонятно почему но main.js не создается, 26 | // но при этом решается задача по отключению кодсплиттинга 27 | manualChunks: () => 'main.js' // 28 | } 29 | } 30 | }, 31 | plugins: [ 32 | vuePlugin(), 33 | Components({ 34 | dts: `${pathSrc}/types/components.d.ts`, 35 | dirs: [`${pathSrc}/components`], 36 | resolvers: [ 37 | IconsResolver({ 38 | prefix: '', 39 | customCollections: ['svg'] 40 | }) 41 | ] 42 | }), 43 | Icons({ 44 | customCollections: { 45 | svg: FileSystemIconLoader(`${pathSrc}/assets/svg`) 46 | }, 47 | iconCustomizer (collection, icons, props) { 48 | if (collection === 'svg') { 49 | props.width = '20px' 50 | props.height = '20px' 51 | } 52 | } 53 | }) 54 | ], 55 | resolve: { 56 | alias: { 57 | '@': pathSrc 58 | } 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /src/main/image-compressor.ts: -------------------------------------------------------------------------------- 1 | import { isFile, isFolder, getFileSize } from './utils' 2 | import { 3 | ensureDirSync, 4 | readFile, 5 | writeFile, 6 | readdir, 7 | copyFileSync, 8 | unlinkSync 9 | } from 'fs-extra' 10 | import { execFile } from 'child_process' 11 | import mozjpeg from 'mozjpeg' 12 | import pngquant from 'pngquant-bin' 13 | import gifsicle from 'gifsicle' 14 | import cwebp from 'cwebp-bin' 15 | import svg from 'svgo' 16 | import junk from 'junk' 17 | import mime from 'mime-types' 18 | import queue from 'queue' 19 | import path from 'path' 20 | import util from 'util' 21 | import { store } from '../main/store' 22 | import type { BrowserWindow } from 'electron' 23 | import type { DroppedFile } from '../renderer/types' 24 | import type { FileOutput, FileSize } from './types' 25 | 26 | const readdirAsync = util.promisify(readdir) 27 | 28 | const MIN_FOLDER = 'minified' 29 | const MIN_SUFFIX = '.min' 30 | const MIME_TYPE_ENUM = { 31 | jpg: 'image/jpeg', 32 | png: 'image/png', 33 | svg: 'image/svg+xml', 34 | gif: 'image/gif', 35 | folder: '' 36 | } 37 | 38 | export class ImageOptimizer { 39 | #queue: queue 40 | #context 41 | files: DroppedFile[] 42 | 43 | constructor (files: DroppedFile[] = [], context: BrowserWindow) { 44 | this.#queue = queue({ results: [], concurrency: 10 }) 45 | this.#context = context 46 | 47 | this.files = files 48 | } 49 | 50 | start () { 51 | const timeStart = new Date() 52 | 53 | this.#context.webContents.send('optimization-start') 54 | 55 | this.#optimize(this.files) 56 | this.#queue.on('end', () => { 57 | const timeEnd = new Date() 58 | const timeSpent = `${(timeEnd.valueOf() - timeStart.valueOf()) / 1000}s` 59 | 60 | this.#context.webContents.send('optimization-complete') 61 | this.#context.webContents.send('job-time', timeSpent) 62 | }) 63 | } 64 | 65 | #optimize (files: DroppedFile[]) { 66 | files.forEach(async file => { 67 | if (!Object.values(MIME_TYPE_ENUM).includes(file.type)) { 68 | return 69 | } 70 | 71 | if (isFile(file.path)) { 72 | let { name, ext, dir } = path.parse(file.path) 73 | const isAddToSubfolder = store.app.get('addToSubfolder') 74 | const convertToWebp = store.app.get('convertToWebp') 75 | const availableExtToWebp = ['.png', '.jpg', '.jpeg'] 76 | 77 | if (convertToWebp && availableExtToWebp.includes(ext)) { 78 | ext = '.webp' 79 | } 80 | 81 | const fileName = store.app.get('addMinSuffix') 82 | ? `${name}${MIN_SUFFIX}${ext}` 83 | : `${name}${ext}` 84 | 85 | const output = isAddToSubfolder 86 | ? `${dir}/${MIN_FOLDER}/${fileName}` 87 | : `${dir}/${fileName}` 88 | 89 | if (isAddToSubfolder) { 90 | ensureDirSync(`${dir}/${MIN_FOLDER}`) 91 | } 92 | 93 | this.#queue.push(() => this.#processFile(file, output)) 94 | } 95 | 96 | if (isFolder(file.path)) { 97 | const folderPath = file.path 98 | const files = (await readdirAsync(file.path)) as string[] 99 | const _files: DroppedFile[] = [] 100 | 101 | files.filter(junk.not).forEach(file => { 102 | if (isFolder(`${folderPath}/${file}`)) return 103 | 104 | _files.push({ 105 | name: file, 106 | path: `${folderPath}/${file}`, 107 | type: mime.lookup(file) as string 108 | }) 109 | }) 110 | 111 | if (_files.length) { 112 | this.#optimize(_files) 113 | } 114 | } 115 | }) 116 | 117 | this.#queue.start() 118 | } 119 | 120 | #processFile = (file: DroppedFile, output: string) => { 121 | const originalSize = getFileSize(file.path) 122 | 123 | return new Promise((resolve, reject) => { 124 | const toWebp = () => { 125 | execFile(cwebp, [file.path, '-o', output], (err: any) => { 126 | if (err) { 127 | console.log(err) 128 | reject(err) 129 | } 130 | 131 | const compressedSize = getFileSize(output) 132 | this.#sendToRenderer(file, originalSize, compressedSize) 133 | resolve() 134 | }) 135 | } 136 | 137 | switch (file.type) { 138 | case MIME_TYPE_ENUM.jpg: { 139 | const { quality } = store.app.get('mozjpeg') 140 | 141 | let originalFile: string 142 | const isAddTempFile = 143 | !store.app.get('addToSubfolder') && !store.app.get('addMinSuffix') 144 | const convertToWebp = store.app.get('convertToWebp') 145 | 146 | if (isAddTempFile) { 147 | originalFile = output + '.tmp' 148 | copyFileSync(file.path, originalFile) 149 | } else { 150 | originalFile = file.path 151 | } 152 | 153 | if (convertToWebp) { 154 | toWebp() 155 | } else { 156 | execFile( 157 | mozjpeg, 158 | ['-quality', `${quality}`, '-outfile', output, originalFile], 159 | err => { 160 | if (err) { 161 | console.log(err) 162 | reject(err) 163 | } 164 | 165 | const compressedSize = getFileSize(output) 166 | this.#sendToRenderer(file, originalSize, compressedSize) 167 | resolve() 168 | } 169 | ) 170 | } 171 | if (isAddTempFile) unlinkSync(originalFile) 172 | break 173 | } 174 | 175 | case MIME_TYPE_ENUM.png: { 176 | const { qualityMin, qualityMax } = store.app.get('pngquant') 177 | const convertToWebp = store.app.get('convertToWebp') 178 | 179 | if (convertToWebp) { 180 | toWebp() 181 | } else { 182 | execFile( 183 | pngquant, 184 | [ 185 | '--quality', 186 | `${qualityMin}-${qualityMax}`, 187 | '-fo', 188 | output, 189 | file.path 190 | ], 191 | err => { 192 | if (err) { 193 | console.log(err) 194 | reject(err) 195 | } 196 | 197 | const compressedSize = getFileSize(output) 198 | this.#sendToRenderer(file, originalSize, compressedSize) 199 | resolve() 200 | } 201 | ) 202 | } 203 | break 204 | } 205 | 206 | case MIME_TYPE_ENUM.gif: { 207 | execFile(gifsicle, ['-o', output, file.path], err => { 208 | if (err) { 209 | console.log(err) 210 | reject(err) 211 | } 212 | 213 | const compressedSize = getFileSize(output) 214 | this.#sendToRenderer(file, originalSize, compressedSize) 215 | resolve() 216 | }) 217 | break 218 | } 219 | 220 | case MIME_TYPE_ENUM.svg: { 221 | readFile(file.path, (err, buffer) => { 222 | if (err) { 223 | console.log(err) 224 | reject(err) 225 | } 226 | 227 | const { data } = svg.optimize(buffer) 228 | writeFile(output, data, err => { 229 | if (err) console.log(err) 230 | 231 | const compressedSize = getFileSize(output) 232 | this.#sendToRenderer(file, originalSize, compressedSize) 233 | resolve() 234 | }) 235 | }) 236 | break 237 | } 238 | } 239 | }) 240 | } 241 | 242 | #formatOutputData ( 243 | file: DroppedFile, 244 | originalSize: FileSize, 245 | compressedSize: FileSize 246 | ): FileOutput { 247 | return { 248 | name: file.name, 249 | path: file.path, 250 | originalSize, 251 | compressedSize, 252 | compressionPercentage: Number( 253 | Math.abs( 254 | compressedSize.bytes * (100 / originalSize.bytes) - 100 255 | ).toFixed(2) 256 | ) 257 | } 258 | } 259 | 260 | #sendToRenderer ( 261 | file: DroppedFile, 262 | originalSize: FileSize, 263 | compressedSize: FileSize 264 | ) { 265 | this.#context.webContents.send( 266 | 'file-complete', 267 | this.#formatOutputData(file, originalSize, compressedSize) 268 | ) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron' 2 | import path from 'path' 3 | import { store } from './store' 4 | import { checkForUpdate } from './update-check' 5 | import { ImageOptimizer } from './image-compressor' 6 | import { createMenu } from './menu' 7 | 8 | const isDev = process.env.NODE_ENV === 'development' 9 | let mainWindow: BrowserWindow 10 | 11 | function createWindow () { 12 | const bounds = store.app.get('bounds') 13 | mainWindow = new BrowserWindow({ 14 | width: 550, 15 | height: 370, 16 | ...bounds, 17 | titleBarStyle: 'hidden', 18 | resizable: false, 19 | backgroundColor: '#212123', 20 | webPreferences: { 21 | preload: path.resolve(__dirname, 'preload.js'), 22 | nodeIntegration: true, 23 | contextIsolation: true 24 | } 25 | }) 26 | 27 | if (isDev) { 28 | const rendererPort = process.argv[2] 29 | mainWindow.loadURL(`http://localhost:${rendererPort}`) 30 | mainWindow.webContents.openDevTools({ mode: 'detach' }) 31 | } else { 32 | mainWindow.loadFile(path.resolve(app.getAppPath(), 'src/renderer/index.html')) 33 | } 34 | 35 | mainWindow.on('close', () => { 36 | store.app.set('bounds', mainWindow.getBounds()) 37 | }) 38 | 39 | return { mainWindow } 40 | } 41 | 42 | function init () { 43 | createWindow() 44 | checkForUpdate(mainWindow) 45 | Menu.setApplicationMenu(Menu.buildFromTemplate(createMenu(mainWindow))) 46 | } 47 | 48 | app.whenReady().then(async () => { 49 | if (isDev) { 50 | const { default: installExtension, VUEJS3_DEVTOOLS } = await import( 51 | 'electron-devtools-installer' 52 | ) 53 | installExtension(VUEJS3_DEVTOOLS) 54 | } 55 | 56 | init() 57 | 58 | app.on('activate', function () { 59 | if (BrowserWindow.getAllWindows().length === 0) { 60 | init() 61 | } 62 | checkForUpdate(mainWindow) 63 | }) 64 | }) 65 | 66 | app.on('window-all-closed', function () { 67 | if (process.platform !== 'darwin') app.quit() 68 | }) 69 | 70 | ipcMain.on('drop', (_, files = []) => { 71 | const optimizer = new ImageOptimizer(files, mainWindow) 72 | optimizer.start() 73 | }) 74 | 75 | ipcMain.on('open-url', (event, url) => { 76 | shell.openExternal(url) 77 | }) 78 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import type { MenuItemConstructorOptions } from 'electron' 2 | import { app, dialog, shell, BrowserWindow } from 'electron' 3 | import { version, author } from '../../package.json' 4 | import os from 'os' 5 | import { getFilesOrDirs } from './utils' 6 | import { ImageOptimizer } from './image-compressor' 7 | 8 | const isMac = process.platform === 'darwin' 9 | const year = new Date().getFullYear() 10 | 11 | if (isMac) { 12 | app.setAboutPanelOptions({ 13 | applicationName: 'Image Optimizer', 14 | applicationVersion: version, 15 | version, 16 | copyright: `${author.name}\n ©2021-${year}` 17 | }) 18 | } 19 | 20 | const createSubmenu = ( 21 | context: BrowserWindow 22 | ): MenuItemConstructorOptions[] => { 23 | if (isMac) { 24 | return [ 25 | { 26 | label: 'About Image Optimizer', 27 | role: 'about' 28 | }, 29 | { 30 | type: 'separator' 31 | }, 32 | { 33 | label: 'Preferences', 34 | accelerator: 'CommandOrControl+,', 35 | click () { 36 | context.webContents.send('menu:preferences') 37 | } 38 | }, 39 | { 40 | type: 'separator' 41 | }, 42 | { 43 | label: 'Hide Image Optimizer', 44 | accelerator: 'Command+H' 45 | }, 46 | { 47 | label: 'Hide Others', 48 | accelerator: 'Command+Shift+H' 49 | }, 50 | { 51 | label: 'Show All' 52 | }, 53 | { 54 | type: 'separator' 55 | }, 56 | { 57 | label: 'Quit Image Optimizer', 58 | role: 'quit', 59 | accelerator: 'CommandOrControl+Q' 60 | } 61 | ] 62 | } 63 | return [] 64 | } 65 | 66 | export const createMenu = ( 67 | context: BrowserWindow 68 | ): MenuItemConstructorOptions[] => { 69 | const imageOptimizer = { 70 | label: 'Image Optimizer', 71 | submenu: createSubmenu(context) 72 | } 73 | 74 | const file = { 75 | label: 'File', 76 | submenu: [ 77 | { 78 | label: 'Open Images', 79 | async click () { 80 | const { filePaths } = await dialog.showOpenDialog({ 81 | properties: ['openFile', 'openDirectory', 'multiSelections'] 82 | }) 83 | console.log(filePaths) 84 | if (filePaths.length) { 85 | const files = getFilesOrDirs(filePaths) 86 | const optimizer = new ImageOptimizer(files, context) 87 | context.webContents.send('drop-from-dialog') 88 | optimizer.start() 89 | } 90 | }, 91 | accelerator: 'CommandOrControl+O' 92 | } 93 | ] 94 | } as MenuItemConstructorOptions 95 | 96 | const help = { 97 | label: 'Help', 98 | role: 'help', 99 | submenu: [ 100 | { 101 | label: 'View in GitHub', 102 | click () { 103 | shell.openExternal('https://github.com/antonreshetov/image-optimizer') 104 | } 105 | }, 106 | { 107 | label: 'Report Issue', 108 | click () { 109 | shell.openExternal( 110 | 'https://github.com/antonreshetov/image-optimizer/issues/new' 111 | ) 112 | } 113 | }, 114 | { 115 | label: 'Donate', 116 | submenu: [ 117 | { 118 | label: 'PayPal', 119 | click () { 120 | shell.openExternal('https://paypal.me/antonreshetov') 121 | } 122 | }, 123 | { 124 | label: 'Patreon', 125 | click () { 126 | shell.openExternal('https://patreon.com/antonreshetov') 127 | } 128 | }, 129 | { 130 | label: 'Ko-Fi', 131 | click () { 132 | shell.openExternal('https://ko-fi.com/antonreshetov') 133 | } 134 | } 135 | ] 136 | }, 137 | { 138 | label: 'About', 139 | click () { 140 | dialog.showMessageBox(BrowserWindow.getFocusedWindow()!, { 141 | title: 'Image Optimizer', 142 | message: 'Image Optimizer', 143 | type: 'info', 144 | detail: ` 145 | Version: ${version} 146 | Electron: ${process.versions.electron} 147 | Chrome: ${process.versions.chrome} 148 | Node.js: ${process.versions.node} 149 | V8: ${process.versions.v8} 150 | OS: ${os.type()} ${os.arch()} ${os.release()} 151 | 152 | ©2021-${year} Anton Reshetov 153 | ` 154 | }) 155 | } 156 | } 157 | ] 158 | } as MenuItemConstructorOptions 159 | 160 | return [imageOptimizer, file, help] 161 | } 162 | -------------------------------------------------------------------------------- /src/main/preload.ts: -------------------------------------------------------------------------------- 1 | import type { EventCallback } from './types' 2 | import { contextBridge, ipcRenderer } from 'electron' 3 | import { store } from '../main/store' 4 | 5 | contextBridge.exposeInMainWorld('electron', { 6 | ipc: { 7 | on: (channel: string, cb: EventCallback) => ipcRenderer.on(channel, cb), 8 | send: (channel: string, data: any, cb?: EventCallback) => { 9 | ipcRenderer.send(channel, data) 10 | if (cb && typeof cb === 'function') { 11 | ipcRenderer.on(channel, cb) 12 | } 13 | }, 14 | removeListener: (channel: string, cb: EventCallback) => 15 | ipcRenderer.removeListener(channel, cb), 16 | removeListeners: (channel: string) => 17 | ipcRenderer.removeAllListeners(channel) 18 | }, 19 | store: { 20 | set: (key: any, value: any) => store.app.set(key, value), 21 | get: (key: any) => store.app.get(key), 22 | on: (key: any, cb: any) => store.app.onDidChange(key, cb) 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /src/main/store/index.ts: -------------------------------------------------------------------------------- 1 | import app from './module/app' 2 | 3 | export const store = { 4 | app 5 | } 6 | -------------------------------------------------------------------------------- /src/main/store/module/app.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store' 2 | import type { StoreSchema } from '../../types' 3 | 4 | export default new Store({ 5 | name: 'app', 6 | watch: true, 7 | 8 | schema: { 9 | bounds: { 10 | type: 'object', 11 | default: {} 12 | }, 13 | addToSubfolder: { 14 | type: 'boolean', 15 | default: true 16 | }, 17 | addMinSuffix: { 18 | type: 'boolean', 19 | default: false 20 | }, 21 | clearResultList: { 22 | type: 'boolean', 23 | default: false 24 | }, 25 | animationOnCompletion: { 26 | type: 'boolean', 27 | default: true 28 | }, 29 | concurrency: { 30 | type: 'number', 31 | default: 10 32 | }, 33 | mozjpeg: { 34 | type: 'object', 35 | properties: { 36 | quality: { 37 | type: 'number', 38 | default: 75 39 | } 40 | }, 41 | default: {} 42 | }, 43 | pngquant: { 44 | type: 'object', 45 | properties: { 46 | qualityMin: { 47 | type: 'number', 48 | default: 75 49 | }, 50 | qualityMax: { 51 | type: 'number', 52 | default: 85 53 | } 54 | }, 55 | default: {} 56 | }, 57 | convertToWebp: { 58 | type: 'boolean', 59 | default: false 60 | } 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /src/main/types/cwebp-bin.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cwebp-bin' 2 | -------------------------------------------------------------------------------- /src/main/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { IpcRendererEvent } from 'electron' 2 | 3 | declare interface EventCallback { 4 | (event?: IpcRendererEvent, ...args: any[]): void 5 | } 6 | 7 | declare global { 8 | interface Window { 9 | electron: { 10 | ipc: { 11 | on: (channel: string, cb: EventCallback) => void 12 | send: (channel: string, data: any, cb?: EventCallback) => void 13 | removeListener: (channel: string, cb: EventCallback) => void 14 | removeListeners: (channel: string) => void 15 | } 16 | store: { 17 | set: (key: any, value: any) => void 18 | get: (key: any) => any 19 | on: (key: any, cb: any) => void 20 | } 21 | } 22 | } 23 | } 24 | 25 | export interface StoreSchema { 26 | bounds: object 27 | addToSubfolder: boolean 28 | addMinSuffix: boolean 29 | clearResultList: boolean 30 | animationOnCompletion: boolean 31 | concurrency: number 32 | mozjpeg: { 33 | quality: number 34 | } 35 | pngquant: { 36 | qualityMin: number 37 | qualityMax: number 38 | } 39 | convertToWebp: boolean 40 | } 41 | 42 | export interface FileSize { 43 | bytes: number 44 | readable: string 45 | } 46 | 47 | export interface FileOutput { 48 | name: string 49 | path: string 50 | originalSize: FileSize 51 | compressedSize: FileSize 52 | compressionPercentage: number 53 | } 54 | -------------------------------------------------------------------------------- /src/main/types/vite.shim.d.ts: -------------------------------------------------------------------------------- 1 | declare type Plugin$1 = any 2 | -------------------------------------------------------------------------------- /src/main/update-check.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import type { BrowserWindow } from 'electron' 3 | import { version } from '../../package.json' 4 | const isDev = process.env.NODE_ENV === 'development' 5 | 6 | export async function checkForUpdate (context: BrowserWindow) { 7 | if (isDev) return 8 | 9 | const res = await axios.get( 10 | 'https://github.com/antonreshetov/image-optimizer/releases/latest' 11 | ) 12 | 13 | if (res) { 14 | const latest = res.request.socket._httpMessage.path 15 | .split('/') 16 | .pop() 17 | .substring(1) 18 | if (latest !== version) { 19 | context.webContents.send('update-available') 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/utils/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import mime from 'mime-types' 4 | import type { FileSize } from '../types' 5 | import type { DroppedFile } from '../../renderer/types' 6 | 7 | export const isFile = (path: string) => { 8 | const stat = fs.lstatSync(path) 9 | return stat.isFile() 10 | } 11 | 12 | export const isFolder = (path: string) => { 13 | const stat = fs.lstatSync(path) 14 | return stat.isDirectory() 15 | } 16 | 17 | export const getFileSize = (path: string): FileSize => { 18 | const stat = fs.lstatSync(path) 19 | return { 20 | bytes: stat.size, 21 | readable: formatBytes(stat.size) 22 | } 23 | } 24 | 25 | export const getFilesOrDirs = (paths: string[]): DroppedFile[] => { 26 | return paths.map(p => { 27 | const { name, ext } = path.parse(p) 28 | return { 29 | name: name + ext, 30 | path: p, 31 | type: isFolder(p) ? '' : mime.lookup(p) as string 32 | } 33 | }) 34 | } 35 | 36 | export const formatBytes = (bytes: number, decimals = 2) => { 37 | if (bytes === 0) return '0 Bytes' 38 | 39 | const k = 1024 40 | const dm = decimals < 0 ? 0 : decimals 41 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 42 | 43 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 44 | 45 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] 46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 55 | 56 | 95 | -------------------------------------------------------------------------------- /src/renderer/assets/styles/base.css: -------------------------------------------------------------------------------- 1 | 2 | :root { 3 | --toolbar-height: 30px; 4 | --footer-height: 30px; 5 | 6 | --color-bg: hsl(240, 3%, 13%); 7 | --color-text: hsl(0, 0%, 67%); 8 | --color-gray-100: hsl(240, 3%, 16%); 9 | --color-gray-200: hsl(240, 3%, 20%); 10 | --color-gray-300: hsl(240, 3%, 30%); 11 | --color-gray-500: hsl(240, 3%, 50%); 12 | --color-gray-700: hsl(240, 3%, 70%); 13 | --color-error: hsl(0, 50%, 50%); 14 | --color-green: hsl(120, 61%, 50%); 15 | --color-table-row-even: var(--color-gray-100); 16 | } 17 | 18 | body { 19 | margin: 0; 20 | font-size: 12px; 21 | height: 100vh; 22 | background-color: var(--color-bg); 23 | color: var(--color-text); 24 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 25 | } -------------------------------------------------------------------------------- /src/renderer/assets/svg/arrow-circle-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/assets/svg/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/assets/svg/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/assets/svg/times.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/renderer/components/AppDragArea.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | 49 | 76 | -------------------------------------------------------------------------------- /src/renderer/components/AppFileList.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 110 | 111 | 151 | -------------------------------------------------------------------------------- /src/renderer/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 63 | 64 | 100 | -------------------------------------------------------------------------------- /src/renderer/components/AppSettingRow.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 28 | 29 | 52 | -------------------------------------------------------------------------------- /src/renderer/components/ui/AppInput.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 41 | 42 | 66 | -------------------------------------------------------------------------------- /src/renderer/components/ui/AppPreloader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 54 | -------------------------------------------------------------------------------- /src/renderer/components/ui/AppToggle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | 26 | 51 | -------------------------------------------------------------------------------- /src/renderer/electron.ts: -------------------------------------------------------------------------------- 1 | const { ipc, store } = window.electron 2 | 3 | export { ipc, store } 4 | -------------------------------------------------------------------------------- /src/renderer/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module '*.vue' { 4 | import type { DefineComponent } from 'vue' 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 6 | const component: DefineComponent<{}, {}, any> 7 | export default component 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Image optimizer 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | import router from './router' 4 | import App from './App.vue' 5 | import './assets/styles/base.css' 6 | 7 | createApp(App) 8 | .use(router) 9 | .use(createPinia()) 10 | .mount('#app') 11 | -------------------------------------------------------------------------------- /src/renderer/router.ts: -------------------------------------------------------------------------------- 1 | import { createWebHistory, createRouter } from 'vue-router' 2 | 3 | const history = createWebHistory() 4 | const router = createRouter({ 5 | linkActiveClass: 'active', 6 | history, 7 | routes: [ 8 | { 9 | path: '/', 10 | component: () => import('./views/Main.vue') 11 | }, 12 | { 13 | path: '/settings', 14 | meta: { title: 'Test page' }, 15 | component: () => import('./views/Settings.vue') 16 | } 17 | 18 | ] 19 | }) 20 | 21 | export default router 22 | -------------------------------------------------------------------------------- /src/renderer/store/index.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { store as electronStore } from '@/electron' 3 | import type { AppState } from '@/types' 4 | 5 | export const useStore = defineStore('app', { 6 | state: () => 7 | ({ 8 | files: [], 9 | totalFiles: { 10 | originalSize: 0, 11 | compressedSize: 0 12 | }, 13 | showFileList: false, 14 | jobTime: '-', 15 | settings: { 16 | mozjpeg: { 17 | quality: electronStore.get('mozjpeg.quality') 18 | }, 19 | pngquant: { 20 | qualityMax: electronStore.get('pngquant.qualityMax'), 21 | qualityMin: electronStore.get('pngquant.qualityMin') 22 | }, 23 | convertToWebp: electronStore.get('convertToWebp'), 24 | addMinSuffix: electronStore.get('addMinSuffix'), 25 | addToSubfolder: electronStore.get('addToSubfolder'), 26 | clearResultList: electronStore.get('clearResultList'), 27 | animationOnCompletion: electronStore.get('animationOnCompletion') 28 | } 29 | } as AppState) 30 | }) 31 | -------------------------------------------------------------------------------- /src/renderer/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { FileOutput, StoreSchema } from '../../main/types' 2 | 3 | export interface DroppedFile { 4 | name: string 5 | path: string 6 | type: string 7 | } 8 | 9 | export interface AppState { 10 | files: FileOutput[] 11 | totalFiles: { 12 | originalSize: number 13 | compressedSize: number 14 | } 15 | jobTime: string 16 | showFileList: boolean 17 | settings: Pick< 18 | StoreSchema, 19 | | 'mozjpeg' 20 | | 'pngquant' 21 | | 'addMinSuffix' 22 | | 'convertToWebp' 23 | | 'clearResultList' 24 | | 'addToSubfolder' 25 | | 'animationOnCompletion' 26 | > 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/views/Main.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/renderer/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 126 | 127 | 134 | -------------------------------------------------------------------------------- /src/scripts/build-electron.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'electron-builder' 2 | import config from '../config/electron-builder' 3 | 4 | export default function () { 5 | return build({ config }) 6 | } 7 | -------------------------------------------------------------------------------- /src/scripts/build-vue.ts: -------------------------------------------------------------------------------- 1 | import { build } from 'vite' 2 | import config from '../config/vite' 3 | 4 | export default function () { 5 | return build({ 6 | base: './', 7 | ...config, 8 | mode: 'production' 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/scripts/build.ts: -------------------------------------------------------------------------------- 1 | import buildVue from './build-vue' 2 | import buildElectron from './build-electron' 3 | import chalk from 'chalk' 4 | 5 | process.env.NODE_ENV = 'production' 6 | 7 | async function build () { 8 | console.log() 9 | console.log(`${chalk.blueBright('Build started...')}`) 10 | console.log() 11 | 12 | await buildVue() 13 | await buildElectron() 14 | 15 | console.log() 16 | console.log(`${chalk.greenBright('Build success!')}`) 17 | console.log() 18 | } 19 | 20 | build() 21 | -------------------------------------------------------------------------------- /src/scripts/dev-server.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import type { ChildProcess, SpawnOptionsWithoutStdio } from 'child_process' 3 | import path from 'path' 4 | import chalk from 'chalk' 5 | import chokidar from 'chokidar' 6 | import { createServer } from 'vite' 7 | import viteConfig from '../config/vite' 8 | 9 | process.env.NODE_ENV = 'development' 10 | 11 | let electronProcess: ChildProcess | null 12 | let rendererPort: number | undefined = 0 13 | 14 | async function startRenderer () { 15 | const server = await createServer({ 16 | ...viteConfig, 17 | mode: 'development' 18 | }) 19 | 20 | return await server.listen() 21 | } 22 | 23 | function startElectron () { 24 | if (electronProcess) return 25 | 26 | const args = ['.', rendererPort] as SpawnOptionsWithoutStdio 27 | 28 | electronProcess = spawn('electron', args) 29 | 30 | electronProcess?.stdout?.on('data', data => { 31 | console.log(chalk.blueBright('[Electron] ') + chalk.white(data.toString())) 32 | }) 33 | 34 | electronProcess?.stderr?.on('data', data => { 35 | console.log(chalk.redBright('[Electron] ') + chalk.white(data.toString())) 36 | }) 37 | } 38 | 39 | function restartElectron () { 40 | if (electronProcess) { 41 | electronProcess.kill() 42 | electronProcess = null 43 | } 44 | 45 | startElectron() 46 | } 47 | 48 | async function start () { 49 | console.log() 50 | console.log(`${chalk.blueBright('Starting Electron + Vite Dev Server...')}`) 51 | console.log() 52 | 53 | const devServer = await startRenderer() 54 | // console.log(devServer) 55 | 56 | rendererPort = devServer.config.server.port 57 | 58 | startElectron() 59 | 60 | chokidar 61 | .watch(path.resolve(__dirname, '../src/main'), { 62 | ignored: ['renderer'] 63 | }) 64 | .on('change', () => { 65 | restartElectron() 66 | }) 67 | } 68 | 69 | start() 70 | -------------------------------------------------------------------------------- /tsconfig.electron.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "lib": ["ESNext", "DOM"], 13 | "outDir": "./build", 14 | "paths": { 15 | "@/*": ["./src/*"] 16 | }, 17 | }, 18 | "include": [ 19 | "src/config/*.ts", 20 | "src/scripts/*.ts", 21 | "src/main/**/*.ts", 22 | "src/main/types" 23 | ], 24 | "exclude":[ 25 | "./node_modules", 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "sourceMap": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "outDir": "./build", 15 | "paths": { 16 | "@/*": ["./src/renderer/*"] 17 | }, 18 | "types": ["unplugin-icons/types/vue"] 19 | }, 20 | "include": [ 21 | "src/renderer/**/*.ts", 22 | "src/renderer/**/*.d.ts", 23 | "src/renderer/**/*.tsx", 24 | "src/renderer/**/*.vue", 25 | "src/main/types" 26 | ], 27 | "exclude":[ 28 | "./node_modules" 29 | ] 30 | } --------------------------------------------------------------------------------