├── .editorconfig ├── .erb ├── configs │ ├── .eslintrc │ ├── webpack.config.base.ts │ ├── webpack.config.eslint.ts │ ├── webpack.config.main.prod.ts │ ├── webpack.config.preload.dev.ts │ ├── webpack.config.renderer.dev.dll.ts │ ├── webpack.config.renderer.dev.ts │ ├── webpack.config.renderer.prod.ts │ └── webpack.paths.ts ├── img │ ├── erb-banner.svg │ ├── erb-logo.png │ └── palette-sponsor-banner.svg ├── mocks │ └── fileMock.js └── scripts │ ├── .eslintrc │ ├── check-build-exists.ts │ ├── check-native-dep.js │ ├── check-node-env.js │ ├── check-port-in-use.js │ ├── clean.js │ ├── delete-source-maps.js │ ├── electron-rebuild.js │ ├── link-modules.ts │ └── notarize.js ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-Bug_report.md │ ├── 2-Question.md │ └── 3-Feature_request.md ├── config.yml ├── stale.yml └── workflows │ ├── codeql-analysis.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── assets ├── assets.d.ts ├── entitlements.mac.plist ├── fonts │ └── montserrat │ │ ├── fonts.scss │ │ ├── montserrat-v25-latin-100.woff2 │ │ ├── montserrat-v25-latin-100italic.woff2 │ │ ├── montserrat-v25-latin-200.woff2 │ │ ├── montserrat-v25-latin-200italic.woff2 │ │ ├── montserrat-v25-latin-300.woff2 │ │ ├── montserrat-v25-latin-300italic.woff2 │ │ ├── montserrat-v25-latin-500.woff2 │ │ ├── montserrat-v25-latin-500italic.woff2 │ │ ├── montserrat-v25-latin-600.woff2 │ │ ├── montserrat-v25-latin-600italic.woff2 │ │ ├── montserrat-v25-latin-700.woff2 │ │ ├── montserrat-v25-latin-700italic.woff2 │ │ ├── montserrat-v25-latin-800.woff2 │ │ ├── montserrat-v25-latin-800italic.woff2 │ │ ├── montserrat-v25-latin-900.woff2 │ │ ├── montserrat-v25-latin-900italic.woff2 │ │ ├── montserrat-v25-latin-italic.woff2 │ │ └── montserrat-v25-latin-regular.woff2 ├── icon.ico ├── icon.png ├── icons │ └── 512x512.png └── images │ └── notavailable.png ├── global.d.ts ├── jest.config.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── preview.png ├── react.d.ts ├── release └── app │ ├── package-lock.json │ └── package.json ├── settings.png ├── setup-jest.js ├── src ├── __tests__ │ ├── components │ │ └── Rating.test.tsx │ └── utils │ │ ├── exif.test.ts │ │ └── mocks │ │ ├── automatic1111.mock.ts │ │ ├── comfyui.mock.ts │ │ └── invokeai.mock.ts ├── main │ ├── WorkerManagers │ │ ├── HashWorkerManager.ts │ │ ├── ImageMetadataWorkerManager.ts │ │ ├── ThumbnailWorkerManager.ts │ │ └── interface.ts │ ├── assets │ │ └── dragAndDropIcon.png │ ├── db.ts │ ├── exif.ts │ ├── interfaces.ts │ ├── ipc │ │ ├── fileAttach.ts │ │ ├── fuse.ts │ │ ├── getPaths.ts │ │ ├── image.ts │ │ ├── metadata.ts │ │ ├── model.ts │ │ ├── mtags.ts │ │ ├── openLink.ts │ │ ├── readfile.ts │ │ ├── saveMD.ts │ │ ├── settings.ts │ │ ├── tag.ts │ │ └── watchFolders.ts │ ├── main.ts │ ├── menu.ts │ ├── preload.ts │ ├── util.ts │ └── workers │ │ ├── calculateHash.js │ │ ├── imageMetadata.js │ │ ├── thumbnails.js │ │ └── watcher.js └── renderer │ ├── App.scss │ ├── App.tsx │ ├── components │ ├── BadgeCommaTexts.tsx │ ├── ColorPicker.tsx │ ├── ConfirmDialog.tsx │ ├── ContextMenu.tsx │ ├── CopyText.tsx │ ├── Exif.tsx │ ├── FullLoader.tsx │ ├── Image.tsx │ ├── ImageMetadata.tsx │ ├── ImageZoom.tsx │ ├── LightBox.tsx │ ├── ModelCard.tsx │ ├── ModelTableDetail.tsx │ ├── MultiSelect.tsx │ ├── Navbar.tsx │ ├── Rating.tsx │ ├── StatusBar.tsx │ ├── Tagger.tsx │ ├── TagsTable.tsx │ ├── UpDownButton.tsx │ └── VirtualScroll.tsx │ ├── fuzzy │ ├── images.fuse.ts │ └── models.fuse.ts │ ├── hocs │ ├── detect-os.tsx │ ├── images-loader.tsx │ ├── models-loader.tsx │ ├── notification.tsx │ └── settings-loader.tsx │ ├── hooks │ ├── tabs.ts │ └── useOnUnmount.ts │ ├── index.ejs │ ├── index.tsx │ ├── layouts │ └── Main.layout.tsx │ ├── pages │ ├── aspectRatioHelper.tsx │ ├── checkpoints.tsx │ ├── imageDetail.tsx │ ├── imageMetadata.tsx │ ├── images.tsx │ ├── loras.tsx │ ├── modelDetail.tsx │ ├── modelImageDetail.tsx │ ├── models.tsx │ ├── settings.tsx │ └── testing.tsx │ ├── preload.d.ts │ ├── state │ ├── images.store.ts │ ├── index.ts │ ├── interfaces.ts │ ├── models.store.ts │ ├── navbar.store.ts │ └── settings.store.ts │ └── utils.ts ├── tailwind.config.js ├── themes.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.erb/configs/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "global-require": "off", 4 | "import/no-dynamic-require": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base webpack config used across other specific configs 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; 7 | import webpackPaths from './webpack.paths'; 8 | 9 | const configuration: webpack.Configuration = { 10 | stats: 'errors-only', 11 | 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.[jt]sx?$/, 16 | exclude: /node_modules/, 17 | use: { 18 | loader: 'ts-loader', 19 | options: { 20 | // Remove this line to enable type checking in webpack builds 21 | transpileOnly: true, 22 | compilerOptions: { 23 | module: 'esnext', 24 | }, 25 | }, 26 | }, 27 | }, 28 | ], 29 | }, 30 | 31 | output: { 32 | path: webpackPaths.srcPath, 33 | // https://github.com/webpack/webpack/issues/1114 34 | library: { 35 | type: 'commonjs2', 36 | }, 37 | }, 38 | 39 | /** 40 | * Determine the array of extensions that should be used to resolve modules. 41 | */ 42 | resolve: { 43 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 44 | modules: [webpackPaths.srcPath, 'node_modules'], 45 | // There is no need to add aliases here, the paths in tsconfig get mirrored 46 | plugins: [new TsconfigPathsPlugins()], 47 | }, 48 | 49 | plugins: [ 50 | new webpack.EnvironmentPlugin({ 51 | NODE_ENV: 'production', 52 | }), 53 | ], 54 | 55 | externals: { 56 | sharp: 'commonjs sharp', 57 | sqlite3: 'sqlite3', 58 | }, 59 | }; 60 | 61 | export default configuration; 62 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.eslint.ts: -------------------------------------------------------------------------------- 1 | /* eslint import/no-unresolved: off, import/no-self-import: off */ 2 | 3 | module.exports = require('./webpack.config.renderer.dev').default; 4 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.main.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack config for production electron main process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import { merge } from 'webpack-merge'; 8 | import TerserPlugin from 'terser-webpack-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | import deleteSourceMaps from '../scripts/delete-source-maps'; 14 | import CopyPlugin from 'copy-webpack-plugin'; 15 | 16 | checkNodeEnv('production'); 17 | deleteSourceMaps(); 18 | 19 | const configuration: webpack.Configuration = { 20 | devtool: 'source-map', 21 | 22 | mode: 'production', 23 | 24 | target: 'electron-main', 25 | 26 | entry: { 27 | main: path.join(webpackPaths.srcMainPath, 'main.ts'), 28 | preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), 29 | }, 30 | 31 | output: { 32 | path: webpackPaths.distMainPath, 33 | filename: '[name].js', 34 | library: { 35 | type: 'umd', 36 | }, 37 | }, 38 | 39 | optimization: { 40 | minimizer: [ 41 | new TerserPlugin({ 42 | parallel: true, 43 | }), 44 | ], 45 | }, 46 | 47 | plugins: [ 48 | new BundleAnalyzerPlugin({ 49 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 50 | analyzerPort: 8888, 51 | }), 52 | 53 | /** 54 | * Create global constants which can be configured at compile time. 55 | * 56 | * Useful for allowing different behaviour between development builds and 57 | * release builds 58 | * 59 | * NODE_ENV should be production so that modules do not perform certain 60 | * development checks 61 | */ 62 | new webpack.EnvironmentPlugin({ 63 | NODE_ENV: 'production', 64 | DEBUG_PROD: false, 65 | START_MINIMIZED: false, 66 | }), 67 | 68 | new webpack.DefinePlugin({ 69 | 'process.type': '"browser"', 70 | }), 71 | 72 | new CopyPlugin({ 73 | patterns: [ 74 | { 75 | from: webpackPaths.srcWorkersPath, 76 | to: webpackPaths.distWorkersPath, 77 | }, 78 | { 79 | from: path.join(webpackPaths.srcMainPath, 'assets'), 80 | to: path.join(webpackPaths.distMainPath, 'assets'), 81 | }, 82 | ], 83 | }), 84 | ], 85 | 86 | /** 87 | * Disables webpack processing of __dirname and __filename. 88 | * If you run the bundle in node.js it falls back to these values of node.js. 89 | * https://github.com/webpack/webpack/issues/2010 90 | */ 91 | node: { 92 | __dirname: false, 93 | __filename: false, 94 | }, 95 | }; 96 | 97 | export default merge(baseConfig, configuration); 98 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.preload.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import { merge } from 'webpack-merge'; 4 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 5 | import baseConfig from './webpack.config.base'; 6 | import webpackPaths from './webpack.paths'; 7 | import checkNodeEnv from '../scripts/check-node-env'; 8 | 9 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 10 | // at the dev webpack config is not accidentally run in a production environment 11 | if (process.env.NODE_ENV === 'production') { 12 | checkNodeEnv('development'); 13 | } 14 | 15 | const configuration: webpack.Configuration = { 16 | devtool: 'inline-source-map', 17 | 18 | mode: 'development', 19 | 20 | target: 'electron-preload', 21 | 22 | entry: path.join(webpackPaths.srcMainPath, 'preload.ts'), 23 | 24 | output: { 25 | path: webpackPaths.dllPath, 26 | filename: 'preload.js', 27 | library: { 28 | type: 'umd', 29 | }, 30 | }, 31 | 32 | plugins: [ 33 | new BundleAnalyzerPlugin({ 34 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 35 | }), 36 | 37 | /** 38 | * Create global constants which can be configured at compile time. 39 | * 40 | * Useful for allowing different behaviour between development builds and 41 | * release builds 42 | * 43 | * NODE_ENV should be production so that modules do not perform certain 44 | * development checks 45 | * 46 | * By default, use 'development' as NODE_ENV. This can be overriden with 47 | * 'staging', for example, by changing the ENV variables in the npm scripts 48 | */ 49 | new webpack.EnvironmentPlugin({ 50 | NODE_ENV: 'development', 51 | }), 52 | 53 | new webpack.LoaderOptionsPlugin({ 54 | debug: true, 55 | }), 56 | ], 57 | 58 | /** 59 | * Disables webpack processing of __dirname and __filename. 60 | * If you run the bundle in node.js it falls back to these values of node.js. 61 | * https://github.com/webpack/webpack/issues/2010 62 | */ 63 | node: { 64 | __dirname: false, 65 | __filename: false, 66 | }, 67 | 68 | watch: true, 69 | }; 70 | 71 | export default merge(baseConfig, configuration); 72 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.dll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Builds the DLL for development electron renderer process 3 | */ 4 | 5 | import webpack from 'webpack'; 6 | import path from 'path'; 7 | import { merge } from 'webpack-merge'; 8 | import baseConfig from './webpack.config.base'; 9 | import webpackPaths from './webpack.paths'; 10 | import { dependencies } from '../../package.json'; 11 | import checkNodeEnv from '../scripts/check-node-env'; 12 | 13 | checkNodeEnv('development'); 14 | 15 | const dist = webpackPaths.dllPath; 16 | 17 | const configuration: webpack.Configuration = { 18 | context: webpackPaths.rootPath, 19 | 20 | devtool: 'eval', 21 | 22 | mode: 'development', 23 | 24 | target: 'electron-renderer', 25 | 26 | externals: ['fsevents', 'crypto-browserify'], 27 | 28 | /** 29 | * Use `module` from `webpack.config.renderer.dev.js` 30 | */ 31 | module: require('./webpack.config.renderer.dev').default.module, 32 | 33 | entry: { 34 | renderer: Object.keys(dependencies || {}), 35 | }, 36 | 37 | output: { 38 | path: dist, 39 | filename: '[name].dev.dll.js', 40 | library: { 41 | name: 'renderer', 42 | type: 'var', 43 | }, 44 | }, 45 | 46 | plugins: [ 47 | new webpack.DllPlugin({ 48 | path: path.join(dist, '[name].json'), 49 | name: '[name]', 50 | }), 51 | 52 | /** 53 | * Create global constants which can be configured at compile time. 54 | * 55 | * Useful for allowing different behaviour between development builds and 56 | * release builds 57 | * 58 | * NODE_ENV should be production so that modules do not perform certain 59 | * development checks 60 | */ 61 | new webpack.EnvironmentPlugin({ 62 | NODE_ENV: 'development', 63 | }), 64 | 65 | new webpack.LoaderOptionsPlugin({ 66 | debug: true, 67 | options: { 68 | context: webpackPaths.srcPath, 69 | output: { 70 | path: webpackPaths.dllPath, 71 | }, 72 | }, 73 | }), 74 | ], 75 | }; 76 | 77 | export default merge(baseConfig, configuration); 78 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.dev.ts: -------------------------------------------------------------------------------- 1 | import 'webpack-dev-server'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import webpack from 'webpack'; 5 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 6 | import chalk from 'chalk'; 7 | import { merge } from 'webpack-merge'; 8 | import { execSync, spawn } from 'child_process'; 9 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 10 | import baseConfig from './webpack.config.base'; 11 | import webpackPaths from './webpack.paths'; 12 | import checkNodeEnv from '../scripts/check-node-env'; 13 | 14 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's 15 | // at the dev webpack config is not accidentally run in a production environment 16 | if (process.env.NODE_ENV === 'production') { 17 | checkNodeEnv('development'); 18 | } 19 | 20 | const port = process.env.PORT || 1212; 21 | const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); 22 | const skipDLLs = 23 | module.parent?.filename.includes('webpack.config.renderer.dev.dll') || 24 | module.parent?.filename.includes('webpack.config.eslint'); 25 | 26 | /** 27 | * Warn if the DLL is not built 28 | */ 29 | if ( 30 | !skipDLLs && 31 | !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) 32 | ) { 33 | console.log( 34 | chalk.black.bgYellow.bold( 35 | 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', 36 | ), 37 | ); 38 | execSync('npm run postinstall'); 39 | } 40 | 41 | const configuration: webpack.Configuration = { 42 | devtool: 'inline-source-map', 43 | 44 | mode: 'development', 45 | 46 | target: ['web', 'electron-renderer'], 47 | 48 | entry: [ 49 | `webpack-dev-server/client?http://localhost:${port}/dist`, 50 | 'webpack/hot/only-dev-server', 51 | path.join(webpackPaths.srcRendererPath, 'index.tsx'), 52 | ], 53 | 54 | output: { 55 | path: webpackPaths.distRendererPath, 56 | publicPath: '/', 57 | filename: 'renderer.dev.js', 58 | library: { 59 | type: 'umd', 60 | }, 61 | }, 62 | 63 | resolve: { 64 | fallback: { 65 | path: require.resolve('path-browserify'), 66 | }, 67 | }, 68 | 69 | module: { 70 | rules: [ 71 | { 72 | test: /\.s?(c|a)ss$/, 73 | use: [ 74 | 'style-loader', 75 | { 76 | loader: 'css-loader', 77 | options: { 78 | modules: true, 79 | sourceMap: true, 80 | importLoaders: 1, 81 | }, 82 | }, 83 | 'sass-loader', 84 | ], 85 | include: /\.module\.s?(c|a)ss$/, 86 | }, 87 | { 88 | test: /\.s?css$/, 89 | use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'], 90 | exclude: /\.module\.s?(c|a)ss$/, 91 | }, 92 | // Fonts 93 | { 94 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 95 | type: 'asset/resource', 96 | }, 97 | // Images 98 | { 99 | test: /\.(png|jpg|jpeg|gif)$/i, 100 | type: 'asset/resource', 101 | }, 102 | // SVG 103 | { 104 | test: /\.svg$/, 105 | use: [ 106 | { 107 | loader: '@svgr/webpack', 108 | options: { 109 | prettier: false, 110 | svgo: false, 111 | svgoConfig: { 112 | plugins: [{ removeViewBox: false }], 113 | }, 114 | titleProp: true, 115 | ref: true, 116 | }, 117 | }, 118 | 'file-loader', 119 | ], 120 | }, 121 | ], 122 | }, 123 | plugins: [ 124 | ...(skipDLLs 125 | ? [] 126 | : [ 127 | new webpack.DllReferencePlugin({ 128 | context: webpackPaths.dllPath, 129 | manifest: require(manifest), 130 | sourceType: 'var', 131 | }), 132 | ]), 133 | 134 | new webpack.NoEmitOnErrorsPlugin(), 135 | 136 | /** 137 | * Create global constants which can be configured at compile time. 138 | * 139 | * Useful for allowing different behaviour between development builds and 140 | * release builds 141 | * 142 | * NODE_ENV should be production so that modules do not perform certain 143 | * development checks 144 | * 145 | * By default, use 'development' as NODE_ENV. This can be overriden with 146 | * 'staging', for example, by changing the ENV variables in the npm scripts 147 | */ 148 | new webpack.EnvironmentPlugin({ 149 | NODE_ENV: 'development', 150 | }), 151 | 152 | new webpack.LoaderOptionsPlugin({ 153 | debug: true, 154 | }), 155 | 156 | new ReactRefreshWebpackPlugin(), 157 | 158 | new HtmlWebpackPlugin({ 159 | filename: path.join('index.html'), 160 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 161 | minify: { 162 | collapseWhitespace: true, 163 | removeAttributeQuotes: true, 164 | removeComments: true, 165 | }, 166 | isBrowser: false, 167 | env: process.env.NODE_ENV, 168 | isDevelopment: process.env.NODE_ENV !== 'production', 169 | nodeModules: webpackPaths.appNodeModulesPath, 170 | }), 171 | ], 172 | 173 | node: { 174 | __dirname: false, 175 | __filename: false, 176 | }, 177 | 178 | devServer: { 179 | port, 180 | compress: true, 181 | hot: true, 182 | headers: { 'Access-Control-Allow-Origin': '*' }, 183 | static: { 184 | publicPath: '/', 185 | }, 186 | historyApiFallback: { 187 | verbose: true, 188 | }, 189 | setupMiddlewares(middlewares) { 190 | console.log('Starting preload.js builder...'); 191 | const preloadProcess = spawn('npm', ['run', 'start:preload'], { 192 | shell: true, 193 | stdio: 'inherit', 194 | }) 195 | .on('close', (code: number) => process.exit(code!)) 196 | .on('error', (spawnError) => console.error(spawnError)); 197 | 198 | console.log('Starting Main Process...'); 199 | let args = ['run', 'start:main']; 200 | if (process.env.MAIN_ARGS) { 201 | args = args.concat( 202 | ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(), 203 | ); 204 | } 205 | spawn('npm', args, { 206 | shell: true, 207 | stdio: 'inherit', 208 | }) 209 | .on('close', (code: number) => { 210 | preloadProcess.kill(); 211 | process.exit(code!); 212 | }) 213 | .on('error', (spawnError) => console.error(spawnError)); 214 | return middlewares; 215 | }, 216 | }, 217 | }; 218 | 219 | export default merge(baseConfig, configuration); 220 | -------------------------------------------------------------------------------- /.erb/configs/webpack.config.renderer.prod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build config for electron renderer process 3 | */ 4 | 5 | import path from 'path'; 6 | import webpack from 'webpack'; 7 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; 10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; 11 | import { merge } from 'webpack-merge'; 12 | import TerserPlugin from 'terser-webpack-plugin'; 13 | import baseConfig from './webpack.config.base'; 14 | import webpackPaths from './webpack.paths'; 15 | import checkNodeEnv from '../scripts/check-node-env'; 16 | import deleteSourceMaps from '../scripts/delete-source-maps'; 17 | 18 | checkNodeEnv('production'); 19 | deleteSourceMaps(); 20 | 21 | const configuration: webpack.Configuration = { 22 | devtool: 'source-map', 23 | 24 | mode: 'production', 25 | 26 | target: ['web', 'electron-renderer'], 27 | 28 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], 29 | 30 | output: { 31 | path: webpackPaths.distRendererPath, 32 | publicPath: './', 33 | filename: 'renderer.js', 34 | library: { 35 | type: 'umd', 36 | }, 37 | }, 38 | 39 | resolve: { 40 | fallback: { 41 | path: require.resolve('path-browserify'), 42 | }, 43 | }, 44 | 45 | module: { 46 | rules: [ 47 | { 48 | test: /\.s?(a|c)ss$/, 49 | use: [ 50 | MiniCssExtractPlugin.loader, 51 | { 52 | loader: 'css-loader', 53 | options: { 54 | modules: true, 55 | sourceMap: true, 56 | importLoaders: 1, 57 | }, 58 | }, 59 | 'sass-loader', 60 | ], 61 | include: /\.module\.s?(c|a)ss$/, 62 | }, 63 | { 64 | test: /\.s?(a|c)ss$/, 65 | use: [ 66 | MiniCssExtractPlugin.loader, 67 | 'css-loader', 68 | 'postcss-loader', 69 | 'sass-loader', 70 | ], 71 | exclude: /\.module\.s?(c|a)ss$/, 72 | }, 73 | // Fonts 74 | { 75 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 76 | type: 'asset/resource', 77 | }, 78 | // Images 79 | { 80 | test: /\.(png|jpg|jpeg|gif)$/i, 81 | type: 'asset/resource', 82 | }, 83 | // SVG 84 | { 85 | test: /\.svg$/, 86 | use: [ 87 | { 88 | loader: '@svgr/webpack', 89 | options: { 90 | prettier: false, 91 | svgo: false, 92 | svgoConfig: { 93 | plugins: [{ removeViewBox: false }], 94 | }, 95 | titleProp: true, 96 | ref: true, 97 | }, 98 | }, 99 | 'file-loader', 100 | ], 101 | }, 102 | ], 103 | }, 104 | 105 | optimization: { 106 | minimize: true, 107 | minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], 108 | }, 109 | 110 | plugins: [ 111 | /** 112 | * Create global constants which can be configured at compile time. 113 | * 114 | * Useful for allowing different behaviour between development builds and 115 | * release builds 116 | * 117 | * NODE_ENV should be production so that modules do not perform certain 118 | * development checks 119 | */ 120 | new webpack.EnvironmentPlugin({ 121 | NODE_ENV: 'production', 122 | DEBUG_PROD: false, 123 | }), 124 | 125 | new MiniCssExtractPlugin({ 126 | filename: 'style.css', 127 | }), 128 | 129 | new BundleAnalyzerPlugin({ 130 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', 131 | analyzerPort: 8889, 132 | }), 133 | 134 | new HtmlWebpackPlugin({ 135 | filename: 'index.html', 136 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), 137 | minify: { 138 | collapseWhitespace: true, 139 | removeAttributeQuotes: true, 140 | removeComments: true, 141 | }, 142 | isBrowser: false, 143 | isDevelopment: false, 144 | }), 145 | 146 | new webpack.DefinePlugin({ 147 | 'process.type': '"renderer"', 148 | }), 149 | ], 150 | }; 151 | 152 | export default merge(baseConfig, configuration); 153 | -------------------------------------------------------------------------------- /.erb/configs/webpack.paths.ts: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const rootPath = path.join(__dirname, '../..'); 4 | 5 | const dllPath = path.join(__dirname, '../dll'); 6 | 7 | const srcPath = path.join(rootPath, 'src'); 8 | const srcMainPath = path.join(srcPath, 'main'); 9 | const srcWorkersPath = path.join(srcMainPath, 'workers'); 10 | const srcRendererPath = path.join(srcPath, 'renderer'); 11 | 12 | const releasePath = path.join(rootPath, 'release'); 13 | const appPath = path.join(releasePath, 'app'); 14 | const appPackagePath = path.join(appPath, 'package.json'); 15 | const appNodeModulesPath = path.join(appPath, 'node_modules'); 16 | const srcNodeModulesPath = path.join(srcPath, 'node_modules'); 17 | 18 | const distPath = path.join(appPath, 'dist'); 19 | const distMainPath = path.join(distPath, 'main'); 20 | const distWorkersPath = path.join(distMainPath, 'workers'); 21 | const distRendererPath = path.join(distPath, 'renderer'); 22 | 23 | const buildPath = path.join(releasePath, 'build'); 24 | 25 | export default { 26 | rootPath, 27 | dllPath, 28 | srcPath, 29 | srcMainPath, 30 | srcWorkersPath, 31 | srcRendererPath, 32 | releasePath, 33 | appPath, 34 | appPackagePath, 35 | appNodeModulesPath, 36 | srcNodeModulesPath, 37 | distPath, 38 | distMainPath, 39 | distWorkersPath, 40 | distRendererPath, 41 | buildPath, 42 | }; 43 | -------------------------------------------------------------------------------- /.erb/img/erb-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/.erb/img/erb-logo.png -------------------------------------------------------------------------------- /.erb/mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | export default 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /.erb/scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": "off", 4 | "global-require": "off", 5 | "import/no-dynamic-require": "off", 6 | "import/no-extraneous-dependencies": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.erb/scripts/check-build-exists.ts: -------------------------------------------------------------------------------- 1 | // Check if the renderer and main bundles are built 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import fs from 'fs'; 5 | import webpackPaths from '../configs/webpack.paths'; 6 | 7 | const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); 8 | const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); 9 | 10 | if (!fs.existsSync(mainPath)) { 11 | throw new Error( 12 | chalk.whiteBright.bgRed.bold( 13 | 'The main process is not built yet. Build it by running "npm run build:main"' 14 | ) 15 | ); 16 | } 17 | 18 | if (!fs.existsSync(rendererPath)) { 19 | throw new Error( 20 | chalk.whiteBright.bgRed.bold( 21 | 'The renderer process is not built yet. Build it by running "npm run build:renderer"' 22 | ) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.erb/scripts/check-native-dep.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import chalk from 'chalk'; 3 | import { execSync } from 'child_process'; 4 | import { dependencies } from '../../package.json'; 5 | 6 | if (dependencies) { 7 | const dependenciesKeys = Object.keys(dependencies); 8 | const nativeDeps = fs 9 | .readdirSync('node_modules') 10 | .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); 11 | if (nativeDeps.length === 0) { 12 | process.exit(0); 13 | } 14 | try { 15 | // Find the reason for why the dependency is installed. If it is installed 16 | // because of a devDependency then that is okay. Warn when it is installed 17 | // because of a dependency 18 | const { dependencies: dependenciesObject } = JSON.parse( 19 | execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString() 20 | ); 21 | const rootDependencies = Object.keys(dependenciesObject); 22 | const filteredRootDependencies = rootDependencies.filter((rootDependency) => 23 | dependenciesKeys.includes(rootDependency) 24 | ); 25 | if (filteredRootDependencies.length > 0) { 26 | const plural = filteredRootDependencies.length > 1; 27 | console.log(` 28 | ${chalk.whiteBright.bgYellow.bold( 29 | 'Webpack does not work with native dependencies.' 30 | )} 31 | ${chalk.bold(filteredRootDependencies.join(', '))} ${ 32 | plural ? 'are native dependencies' : 'is a native dependency' 33 | } and should be installed inside of the "./release/app" folder. 34 | First, uninstall the packages from "./package.json": 35 | ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} 36 | ${chalk.bold( 37 | 'Then, instead of installing the package to the root "./package.json":' 38 | )} 39 | ${chalk.whiteBright.bgRed.bold('npm install your-package')} 40 | ${chalk.bold('Install the package to "./release/app/package.json"')} 41 | ${chalk.whiteBright.bgGreen.bold( 42 | 'cd ./release/app && npm install your-package' 43 | )} 44 | Read more about native dependencies at: 45 | ${chalk.bold( 46 | 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure' 47 | )} 48 | `); 49 | process.exit(1); 50 | } 51 | } catch (e) { 52 | console.log('Native dependencies could not be checked'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.erb/scripts/check-node-env.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export default function checkNodeEnv(expectedEnv) { 4 | if (!expectedEnv) { 5 | throw new Error('"expectedEnv" not set'); 6 | } 7 | 8 | if (process.env.NODE_ENV !== expectedEnv) { 9 | console.log( 10 | chalk.whiteBright.bgRed.bold( 11 | `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config` 12 | ) 13 | ); 14 | process.exit(2); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.erb/scripts/check-port-in-use.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import detectPort from 'detect-port'; 3 | 4 | const port = process.env.PORT || '1212'; 5 | 6 | detectPort(port, (err, availablePort) => { 7 | if (port !== String(availablePort)) { 8 | throw new Error( 9 | chalk.whiteBright.bgRed.bold( 10 | `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start` 11 | ) 12 | ); 13 | } else { 14 | process.exit(0); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /.erb/scripts/clean.js: -------------------------------------------------------------------------------- 1 | import { rimrafSync } from 'rimraf'; 2 | import fs from 'fs'; 3 | import webpackPaths from '../configs/webpack.paths'; 4 | 5 | const foldersToRemove = [ 6 | webpackPaths.distPath, 7 | webpackPaths.buildPath, 8 | webpackPaths.dllPath, 9 | ]; 10 | 11 | foldersToRemove.forEach((folder) => { 12 | if (fs.existsSync(folder)) rimrafSync(folder); 13 | }); 14 | -------------------------------------------------------------------------------- /.erb/scripts/delete-source-maps.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { rimrafSync } from 'rimraf'; 4 | import webpackPaths from '../configs/webpack.paths'; 5 | 6 | export default function deleteSourceMaps() { 7 | if (fs.existsSync(webpackPaths.distMainPath)) 8 | rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { 9 | glob: true, 10 | }); 11 | if (fs.existsSync(webpackPaths.distRendererPath)) 12 | rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { 13 | glob: true, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /.erb/scripts/electron-rebuild.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import { dependencies } from '../../release/app/package.json'; 4 | import webpackPaths from '../configs/webpack.paths'; 5 | 6 | if ( 7 | Object.keys(dependencies || {}).length > 0 && 8 | fs.existsSync(webpackPaths.appNodeModulesPath) 9 | ) { 10 | const electronRebuildCmd = 11 | '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .'; 12 | const cmd = 13 | process.platform === 'win32' 14 | ? electronRebuildCmd.replace(/\//g, '\\') 15 | : electronRebuildCmd; 16 | execSync(cmd, { 17 | cwd: webpackPaths.appPath, 18 | stdio: 'inherit', 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /.erb/scripts/link-modules.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import webpackPaths from '../configs/webpack.paths'; 3 | 4 | const { srcNodeModulesPath } = webpackPaths; 5 | const { appNodeModulesPath } = webpackPaths; 6 | 7 | if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { 8 | fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); 9 | } 10 | -------------------------------------------------------------------------------- /.erb/scripts/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require('@electron/notarize'); 2 | const { build } = require('../../package.json'); 3 | 4 | exports.default = async function notarizeMacos(context) { 5 | const { electronPlatformName, appOutDir } = context; 6 | if (electronPlatformName !== 'darwin') { 7 | return; 8 | } 9 | 10 | if (process.env.CI !== 'true') { 11 | console.warn('Skipping notarizing step. Packaging is not running in CI'); 12 | return; 13 | } 14 | 15 | if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) { 16 | console.warn( 17 | 'Skipping notarizing step. APPLE_ID and APPLE_ID_PASS env variables must be set' 18 | ); 19 | return; 20 | } 21 | 22 | const appName = context.packager.appInfo.productFilename; 23 | 24 | await notarize({ 25 | appBundleId: build.appId, 26 | appPath: `${appOutDir}/${appName}.app`, 27 | appleId: process.env.APPLE_ID, 28 | appleIdPassword: process.env.APPLE_ID_PASS, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | 25 | .idea 26 | npm-debug.log.* 27 | *.css.d.ts 28 | *.sass.d.ts 29 | *.scss.d.ts 30 | 31 | # eslint ignores hidden directories by default: 32 | # https://github.com/eslint/eslint/issues/8429 33 | !.erb 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'erb', 3 | plugins: ['@typescript-eslint'], 4 | globals: { 5 | BufferEncoding: 'readonly', 6 | }, 7 | rules: { 8 | // A temporary hack related to IDE not resolving correct package.json 9 | 'import/no-extraneous-dependencies': 'off', 10 | 'react/react-in-jsx-scope': 'off', 11 | 'react/jsx-filename-extension': 'off', 12 | 'import/extensions': 'off', 13 | 'import/no-unresolved': 'off', 14 | 'import/no-import-module-exports': 'off', 15 | 'import/prefer-default-export': 'off', 16 | 'no-shadow': 'off', 17 | '@typescript-eslint/no-shadow': 'error', 18 | 'no-unused-vars': 'off', 19 | '@typescript-eslint/no-unused-vars': 'error', 20 | 'react/require-default-props': 'off', 21 | 'no-console': 'off', 22 | 'no-bitwise': 'off', 23 | 'no-plusplus': 'off', 24 | 'no-await-in-loop': 'off', 25 | 'no-async-promise-executor': 'off', 26 | 'no-param-reassign': 'off', 27 | 'react/no-array-index-key': 'off', 28 | 'react/no-unknown-property': 'off', 29 | 'prefer-destructuring': 'off', 30 | 'no-inner-declarations': 'off', 31 | 'class-methods-use-this': 'off', 32 | 'no-continue': 'off', 33 | 'compat/compat': 'off', 34 | 'jsx-a11y/label-has-associated-control': 'off', 35 | 'jsx-a11y/no-noninteractive-tabindex': 'off', 36 | 'react/jsx-props-no-spreading': 'off', 37 | 'jsx-a11y/control-has-associated-label': 'off', 38 | 'func-names': 'off', 39 | }, 40 | parserOptions: { 41 | ecmaVersion: 2020, 42 | sourceType: 'module', 43 | project: './tsconfig.json', 44 | tsconfigRootDir: __dirname, 45 | createDefaultProgram: true, 46 | }, 47 | settings: { 48 | 'import/resolver': { 49 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below 50 | node: {}, 51 | webpack: { 52 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), 53 | }, 54 | typescript: {}, 55 | }, 56 | 'import/parsers': { 57 | '@typescript-eslint/parser': ['.ts', '.tsx'], 58 | }, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.exe binary 3 | *.png binary 4 | *.jpg binary 5 | *.jpeg binary 6 | *.ico binary 7 | *.icns binary 8 | *.eot binary 9 | *.otf binary 10 | *.ttf binary 11 | *.woff binary 12 | *.woff2 binary 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [electron-react-boilerplate, amilajack] 4 | patreon: amilajack 5 | open_collective: electron-react-boilerplate-594 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: You're having technical issues. 🐞 4 | labels: 'bug' 5 | --- 6 | 7 | 8 | 9 | ## Prerequisites 10 | 11 | 12 | 13 | - [ ] Using npm 14 | - [ ] Using an up-to-date [`main` branch](https://github.com/electron-react-boilerplate/electron-react-boilerplate/tree/main) 15 | - [ ] Using latest version of devtools. [Check the docs for how to update](https://electron-react-boilerplate.js.org/docs/dev-tools/) 16 | - [ ] Tried solutions mentioned in [#400](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/400) 17 | - [ ] For issue in production release, add devtools output of `DEBUG_PROD=true npm run build && npm start` 18 | 19 | ## Expected Behavior 20 | 21 | 22 | 23 | ## Current Behavior 24 | 25 | 26 | 27 | ## Steps to Reproduce 28 | 29 | 30 | 31 | 32 | 1. 33 | 34 | 2. 35 | 36 | 3. 37 | 38 | 4. 39 | 40 | ## Possible Solution (Not obligatory) 41 | 42 | 43 | 44 | ## Context 45 | 46 | 47 | 48 | 49 | 50 | ## Your Environment 51 | 52 | 53 | 54 | - Node version : 55 | - electron-react-boilerplate version or branch : 56 | - Operating System and version : 57 | - Link to your project : 58 | 59 | 68 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question.❓ 4 | labels: 'question' 5 | --- 6 | 7 | ## Summary 8 | 9 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: You want something added to the boilerplate. 🎉 4 | labels: 'enhancement' 5 | --- 6 | 7 | 16 | -------------------------------------------------------------------------------- /.github/config.yml: -------------------------------------------------------------------------------- 1 | requiredHeaders: 2 | - Prerequisites 3 | - Expected Behavior 4 | - Current Behavior 5 | - Possible Solution 6 | - Your Environment 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - discussion 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '44 16 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | # To enable auto publishing to github, update your electron publisher 11 | # config in package.json > "build" and remove the conditional below 12 | if: ${{ github.repository_owner == 'electron-react-boilerplate' }} 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | os: [macos-latest] 19 | 20 | steps: 21 | - name: Checkout git repo 22 | uses: actions/checkout@v3 23 | 24 | - name: Install Node and NPM 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 18 28 | cache: npm 29 | 30 | - name: Install and build 31 | run: | 32 | npm install 33 | npm run postinstall 34 | npm run build 35 | 36 | - name: Publish releases 37 | env: 38 | # These values are used for auto updates signing 39 | APPLE_ID: ${{ secrets.APPLE_ID }} 40 | APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASS }} 41 | CSC_LINK: ${{ secrets.CSC_LINK }} 42 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 43 | # This is used for uploading release assets to github 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | run: | 46 | npm exec electron-builder -- --publish always --win --mac --linux 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [macos-latest, windows-latest, ubuntu-latest] 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Install Node.js and NPM 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | cache: npm 22 | 23 | - name: npm install 24 | run: | 25 | npm install 26 | 27 | - name: npm test 28 | env: 29 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | run: | 31 | npm run package 32 | npm run lint 33 | npm exec tsc 34 | npm test 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | .eslintcache 13 | 14 | # Dependency directory 15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 16 | node_modules 17 | 18 | # OSX 19 | .DS_Store 20 | 21 | release/app/dist 22 | release/build 23 | .erb/dll 24 | 25 | .idea 26 | npm-debug.log.* 27 | *.css.d.ts 28 | *.sass.d.ts 29 | *.scss.d.ts 30 | 31 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | npm run test 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "overrides": [ 4 | { 5 | "files": [".prettierrc", ".eslintrc"], 6 | "options": { 7 | "parser": "json" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Electron: Main", 6 | "type": "node", 7 | "request": "launch", 8 | "protocol": "inspector", 9 | "runtimeExecutable": "npm", 10 | "runtimeArgs": ["run", "start"], 11 | "env": { 12 | "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223" 13 | } 14 | }, 15 | { 16 | "name": "Electron: Renderer", 17 | "type": "chrome", 18 | "request": "attach", 19 | "port": 9223, 20 | "webRoot": "${workspaceFolder}", 21 | "timeout": 15000 22 | } 23 | ], 24 | "compounds": [ 25 | { 26 | "name": "Electron: All", 27 | "configurations": ["Electron: Main", "Electron: Renderer"] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | ".eslintrc": "jsonc", 4 | ".prettierrc": "jsonc", 5 | ".eslintignore": "ignore" 6 | }, 7 | 8 | "eslint.validate": [ 9 | "javascript", 10 | "javascriptreact", 11 | "html", 12 | "typescriptreact" 13 | ], 14 | 15 | "javascript.validate.enable": false, 16 | "javascript.format.enable": false, 17 | "typescript.format.enable": false, 18 | 19 | "search.exclude": { 20 | ".git": true, 21 | ".eslintcache": true, 22 | ".erb/dll": true, 23 | "release/{build,app/dist}": true, 24 | "node_modules": true, 25 | "npm-debug.log.*": true, 26 | "test/**/__snapshots__": true, 27 | "package-lock.json": true, 28 | "*.{css,sass,scss}.d.ts": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Electron React Boilerplate 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 | # SD Manager 2 | 3 | Sd Manager is a desktop app to organize models and images generated by Automatic1111 or Comfyui 4 | 5 | ![](./preview.png) 6 | 7 | ## Features 8 | 9 | - Organize and browse images by model 10 | - Rank the images 11 | - Sort the images by rank 12 | - Add notes in markdown 13 | - Insert images in image's notes with markdown 14 | - Check models updates on civitai 15 | 16 | # How to use 17 | 18 | - set the directory where your stable difussion models are 19 | - add folders to watch for images generated 20 | 21 | ![](./settings.png) 22 | 23 | # Search images 24 | 25 | - By default it search images by model name. 26 | - To search by tags you have to prefix t: , for example t:anime 27 | - To search by prompt you have to prefix p: , for example p:1girl 28 | - To search by negative prompt you have to prefix n: , for example n:ugly 29 | 30 | # ComfyUI params 31 | 32 | I recommend this node to save params so SD-Manager can parse the metadata, https://github.com/alexopus/ComfyUI-Image-Saver 33 | 34 | # Notes 35 | 36 | - The database is stored in %AppData%\sd-manager\database.db in case you want to make a backup or edit it 37 | 38 | # Run in development mode 39 | 40 | - Install node v18 41 | - Install modules with npm install 42 | - Run npm run start 43 | -------------------------------------------------------------------------------- /assets/assets.d.ts: -------------------------------------------------------------------------------- 1 | type Styles = Record; 2 | 3 | declare module '*.svg' { 4 | import React = require('react'); 5 | 6 | export const ReactComponent: React.FC>; 7 | 8 | const content: string; 9 | export default content; 10 | } 11 | 12 | declare module '*.png' { 13 | const content: string; 14 | export default content; 15 | } 16 | 17 | declare module '*.jpg' { 18 | const content: string; 19 | export default content; 20 | } 21 | 22 | declare module '*.scss' { 23 | const content: Styles; 24 | export default content; 25 | } 26 | 27 | declare module '*.sass' { 28 | const content: Styles; 29 | export default content; 30 | } 31 | 32 | declare module '*.css' { 33 | const content: Styles; 34 | export default content; 35 | } 36 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.allow-jit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/fonts/montserrat/fonts.scss: -------------------------------------------------------------------------------- 1 | /* montserrat-100 - latin */ 2 | @font-face { 3 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 4 | font-family: 'Montserrat'; 5 | font-style: normal; 6 | font-weight: 100; 7 | src: url('./montserrat-v25-latin-100.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 8 | } 9 | 10 | /* montserrat-100italic - latin */ 11 | @font-face { 12 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 13 | font-family: 'Montserrat'; 14 | font-style: italic; 15 | font-weight: 100; 16 | src: url('./montserrat-v25-latin-100italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 17 | } 18 | 19 | /* montserrat-200 - latin */ 20 | @font-face { 21 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 22 | font-family: 'Montserrat'; 23 | font-style: normal; 24 | font-weight: 200; 25 | src: url('./montserrat-v25-latin-200.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 26 | } 27 | 28 | /* montserrat-200italic - latin */ 29 | @font-face { 30 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 31 | font-family: 'Montserrat'; 32 | font-style: italic; 33 | font-weight: 200; 34 | src: url('./montserrat-v25-latin-200italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 35 | } 36 | 37 | /* montserrat-300 - latin */ 38 | @font-face { 39 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 40 | font-family: 'Montserrat'; 41 | font-style: normal; 42 | font-weight: 300; 43 | src: url('./montserrat-v25-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 44 | } 45 | 46 | /* montserrat-300italic - latin */ 47 | @font-face { 48 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 49 | font-family: 'Montserrat'; 50 | font-style: italic; 51 | font-weight: 300; 52 | src: url('./montserrat-v25-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 53 | } 54 | 55 | /* montserrat-regular - latin */ 56 | @font-face { 57 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 58 | font-family: 'Montserrat'; 59 | font-style: normal; 60 | font-weight: 400; 61 | src: url('./montserrat-v25-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 62 | } 63 | 64 | /* montserrat-italic - latin */ 65 | @font-face { 66 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 67 | font-family: 'Montserrat'; 68 | font-style: italic; 69 | font-weight: 400; 70 | src: url('./montserrat-v25-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 71 | } 72 | 73 | /* montserrat-500 - latin */ 74 | @font-face { 75 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 76 | font-family: 'Montserrat'; 77 | font-style: normal; 78 | font-weight: 500; 79 | src: url('./montserrat-v25-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 80 | } 81 | 82 | /* montserrat-500italic - latin */ 83 | @font-face { 84 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 85 | font-family: 'Montserrat'; 86 | font-style: italic; 87 | font-weight: 500; 88 | src: url('./montserrat-v25-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 89 | } 90 | 91 | /* montserrat-600 - latin */ 92 | @font-face { 93 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 94 | font-family: 'Montserrat'; 95 | font-style: normal; 96 | font-weight: 600; 97 | src: url('./montserrat-v25-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 98 | } 99 | 100 | /* montserrat-600italic - latin */ 101 | @font-face { 102 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 103 | font-family: 'Montserrat'; 104 | font-style: italic; 105 | font-weight: 600; 106 | src: url('./montserrat-v25-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 107 | } 108 | 109 | /* montserrat-700 - latin */ 110 | @font-face { 111 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 112 | font-family: 'Montserrat'; 113 | font-style: normal; 114 | font-weight: 700; 115 | src: url('./montserrat-v25-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 116 | } 117 | 118 | /* montserrat-700italic - latin */ 119 | @font-face { 120 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 121 | font-family: 'Montserrat'; 122 | font-style: italic; 123 | font-weight: 700; 124 | src: url('./montserrat-v25-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 125 | } 126 | 127 | /* montserrat-800 - latin */ 128 | @font-face { 129 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 130 | font-family: 'Montserrat'; 131 | font-style: normal; 132 | font-weight: 800; 133 | src: url('./montserrat-v25-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 134 | } 135 | 136 | /* montserrat-800italic - latin */ 137 | @font-face { 138 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 139 | font-family: 'Montserrat'; 140 | font-style: italic; 141 | font-weight: 800; 142 | src: url('./montserrat-v25-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 143 | } 144 | 145 | /* montserrat-900 - latin */ 146 | @font-face { 147 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 148 | font-family: 'Montserrat'; 149 | font-style: normal; 150 | font-weight: 900; 151 | src: url('./montserrat-v25-latin-900.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 152 | } 153 | 154 | /* montserrat-900italic - latin */ 155 | @font-face { 156 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 157 | font-family: 'Montserrat'; 158 | font-style: italic; 159 | font-weight: 900; 160 | src: url('./montserrat-v25-latin-900italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 161 | } 162 | -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-100.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-100italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-100italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-200.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-200italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-200italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-300.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-300italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-500.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-500italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-600.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-600italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-700.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-700italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-800.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-800italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-900.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-900italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-900italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-italic.woff2 -------------------------------------------------------------------------------- /assets/fonts/montserrat/montserrat-v25-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/fonts/montserrat/montserrat-v25-latin-regular.woff2 -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/icon.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/images/notavailable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/assets/images/notavailable.png -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare const ENV: { 2 | lightboxkey?: string; 3 | lightboxkeytype?: string; 4 | }; 5 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "moduleDirectories": ["node_modules", "release/app/node_modules", "src"], 4 | "moduleFileExtensions": ["js", "jsx", "ts", "tsx", "json"], 5 | "moduleNameMapper": { 6 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", 7 | "\\.(css|less|sass|scss)$": "identity-obj-proxy" 8 | }, 9 | "setupFiles": ["./.erb/scripts/check-build-exists.ts"], 10 | "testEnvironment": "jsdom", 11 | "testEnvironmentOptions": { 12 | "url": "http://localhost/" 13 | }, 14 | "testPathIgnorePatterns": [ 15 | "release/app/dist", 16 | ".erb/dll", 17 | "node_modules", 18 | ".mock.ts" 19 | ], 20 | "transform": { 21 | "\\.(ts|tsx|js|jsx)$": "ts-jest" 22 | }, 23 | "setupFilesAfterEnv": ["./setup-jest.js"] 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sd-manager", 3 | "description": "stable difussion models manager", 4 | "license": "MIT", 5 | "main": "./src/main/main.ts", 6 | "author": "Alberto Palumbo", 7 | "version": "1.6.0", 8 | "scripts": { 9 | "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", 10 | "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", 11 | "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", 12 | "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", 13 | "lint": "cross-env NODE_ENV=development eslint src --ext .js,.jsx,.ts,.tsx", 14 | "prettier": "prettier src --write", 15 | "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", 16 | "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", 17 | "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", 18 | "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only --max-old-space-size=8192 .", 19 | "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", 20 | "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", 21 | "test": "jest --config ./jest.config.json src", 22 | "prepare": "husky install" 23 | }, 24 | "browserslist": [ 25 | "last 1 version", 26 | "> 1%", 27 | "not dead" 28 | ], 29 | "dependencies": { 30 | "@codemirror/lang-json": "^6.0.1", 31 | "@emotion/react": "^11.11.3", 32 | "@paralleldrive/cuid2": "^2.2.2", 33 | "@uiw/react-codemirror": "^4.21.21", 34 | "@uiw/react-md-editor": "^3.25.6", 35 | "axios": "^1.6.7", 36 | "chokidar": "^3.6.0", 37 | "classnames": "^2.5.1", 38 | "copy-webpack-plugin": "^11.0.0", 39 | "electron-debug": "^3.2.0", 40 | "electron-log": "5.0.0", 41 | "electron-updater": "^6.1.7", 42 | "framer-motion": "^10.18.0", 43 | "fuse.js": "^6.6.2", 44 | "html-react-parser": "^4.2.10", 45 | "immer": "^10.0.3", 46 | "jotai": "^2.6.4", 47 | "jotai-immer": "^0.2.0", 48 | "path-browserify": "^1.0.1", 49 | "postcss": "^8.4.35", 50 | "react": "^18.2.0", 51 | "react-click-away-listener": "^2.2.3", 52 | "react-dom": "^18.2.0", 53 | "react-multi-carousel": "^2.8.4", 54 | "react-quick-pinch-zoom": "^5.1.0", 55 | "react-router-dom": "^6.22.0", 56 | "react-tailwindcss-select": "github:albertpb/react-tailwindcss-select", 57 | "react-toastify": "^9.1.3", 58 | "sqlite": "^5.1.1", 59 | "tailwindcss": "^3.4.1" 60 | }, 61 | "devDependencies": { 62 | "@electron/notarize": "^2.2.1", 63 | "@electron/rebuild": "^3.6.0", 64 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", 65 | "@svgr/webpack": "^8.1.0", 66 | "@tailwindcss/typography": "^0.5.10", 67 | "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", 68 | "@testing-library/jest-dom": "^6.4.2", 69 | "@testing-library/react": "^14.2.1", 70 | "@types/jest": "^29.5.12", 71 | "@types/node": "^20.11.17", 72 | "@types/react": "^18.2.55", 73 | "@types/react-dom": "^18.2.19", 74 | "@types/react-test-renderer": "^18.0.7", 75 | "@types/terser-webpack-plugin": "^5.2.0", 76 | "@types/webpack-bundle-analyzer": "^4.7.0", 77 | "@typescript-eslint/eslint-plugin": "^6.21.0", 78 | "@typescript-eslint/parser": "^6.21.0", 79 | "autoprefixer": "^10.4.17", 80 | "browserslist-config-erb": "^0.0.3", 81 | "chalk": "^4.1.2", 82 | "concurrently": "^8.2.2", 83 | "core-js": "^3.35.1", 84 | "cross-env": "^7.0.3", 85 | "css-loader": "^6.10.0", 86 | "css-minimizer-webpack-plugin": "^5.0.1", 87 | "daisyui": "^4.6.2", 88 | "detect-port": "^1.5.1", 89 | "electron": "^27.3.2", 90 | "electron-builder": "^24.9.1", 91 | "electron-devtools-installer": "^3.2.0", 92 | "electronmon": "^2.0.2", 93 | "eslint": "^8.56.0", 94 | "eslint-config-airbnb-base": "^15.0.0", 95 | "eslint-config-erb": "^4.1.0", 96 | "eslint-import-resolver-typescript": "^3.6.1", 97 | "eslint-import-resolver-webpack": "^0.13.8", 98 | "eslint-plugin-compat": "^4.2.0", 99 | "eslint-plugin-import": "^2.29.1", 100 | "eslint-plugin-jest": "^27.6.3", 101 | "eslint-plugin-jsx-a11y": "^6.8.0", 102 | "eslint-plugin-promise": "^6.1.1", 103 | "eslint-plugin-react": "^7.33.2", 104 | "eslint-plugin-react-hooks": "^4.6.0", 105 | "file-loader": "^6.2.0", 106 | "html-webpack-plugin": "^5.6.0", 107 | "husky": "^8.0.3", 108 | "identity-obj-proxy": "^3.0.0", 109 | "jest": "^29.7.0", 110 | "jest-environment-jsdom": "^29.7.0", 111 | "mini-css-extract-plugin": "^2.8.0", 112 | "postcss-loader": "^7.3.4", 113 | "prettier": "^3.2.5", 114 | "react-refresh": "^0.14.0", 115 | "react-test-renderer": "^18.2.0", 116 | "rimraf": "^5.0.5", 117 | "sass": "^1.70.0", 118 | "sass-loader": "^13.3.3", 119 | "style-loader": "^3.3.4", 120 | "terser-webpack-plugin": "^5.3.10", 121 | "ts-jest": "^29.1.2", 122 | "ts-loader": "^9.5.1", 123 | "ts-node": "^10.9.2", 124 | "tsconfig-paths-webpack-plugin": "^4.1.0", 125 | "typescript": "^5.3.3", 126 | "url-loader": "^4.1.1", 127 | "webpack": "^5.90.1", 128 | "webpack-bundle-analyzer": "^4.10.1", 129 | "webpack-cli": "^5.1.4", 130 | "webpack-dev-server": "^4.15.1", 131 | "webpack-merge": "^5.10.0" 132 | }, 133 | "build": { 134 | "productName": "SDManager", 135 | "appId": "org.sd.SDManager", 136 | "asar": true, 137 | "asarUnpack": "**\\*.{node,dll}", 138 | "files": [ 139 | "dist", 140 | "node_modules", 141 | "package.json" 142 | ], 143 | "afterSign": ".erb/scripts/notarize.js", 144 | "mac": { 145 | "target": { 146 | "target": "default", 147 | "arch": [ 148 | "arm64", 149 | "x64" 150 | ] 151 | }, 152 | "type": "distribution", 153 | "hardenedRuntime": true, 154 | "entitlements": "assets/entitlements.mac.plist", 155 | "entitlementsInherit": "assets/entitlements.mac.plist", 156 | "gatekeeperAssess": false 157 | }, 158 | "dmg": { 159 | "contents": [ 160 | { 161 | "x": 130, 162 | "y": 220 163 | }, 164 | { 165 | "x": 410, 166 | "y": 220, 167 | "type": "link", 168 | "path": "/Applications" 169 | } 170 | ] 171 | }, 172 | "win": { 173 | "target": [ 174 | "nsis" 175 | ] 176 | }, 177 | "linux": { 178 | "target": [ 179 | "AppImage" 180 | ], 181 | "category": "Development" 182 | }, 183 | "directories": { 184 | "app": "release/app", 185 | "buildResources": "assets", 186 | "output": "release/build" 187 | }, 188 | "extraResources": [ 189 | "./assets/**" 190 | ], 191 | "publish": { 192 | "provider": "github", 193 | "owner": "albertpb", 194 | "repo": "sd-manager" 195 | } 196 | }, 197 | "devEngines": { 198 | "node": ">=14.x", 199 | "npm": ">=7.x" 200 | }, 201 | "electronmon": { 202 | "patterns": [ 203 | "!**/**", 204 | "src/main/**" 205 | ], 206 | "logLevel": "quiet" 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require: off, import/no-extraneous-dependencies: off */ 2 | 3 | module.exports = { 4 | plugins: [require('tailwindcss'), require('autoprefixer')], 5 | }; 6 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/preview.png -------------------------------------------------------------------------------- /react.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | declare module 'react' { 4 | interface CSSProperties { 5 | '--value'?: number; 6 | '--size'?: string; 7 | '--thickness'?: string; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /release/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sd-manager", 3 | "version": "1.6.0", 4 | "description": "", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Alberto Palumbo" 8 | }, 9 | "main": "./dist/main/main.js", 10 | "scripts": { 11 | "rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", 12 | "postinstall": "npm run rebuild && npm run link-modules", 13 | "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts" 14 | }, 15 | "dependencies": { 16 | "hash-wasm": "^4.10.0", 17 | "sharp": "^0.33.2", 18 | "sqlite3": "^5.1.6", 19 | "chokidar": "^3.5.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/settings.png -------------------------------------------------------------------------------- /setup-jest.js: -------------------------------------------------------------------------------- 1 | const { TextEncoder, TextDecoder } = require('util'); 2 | 3 | global.TextEncoder = TextEncoder; 4 | global.TextDecoder = TextDecoder; 5 | -------------------------------------------------------------------------------- /src/__tests__/components/Rating.test.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render } from '@testing-library/react'; 3 | import Rating from 'renderer/components/Rating'; 4 | 5 | describe('rating component', () => { 6 | it('should render', () => { 7 | expect(render()).toBeTruthy(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/utils/exif.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseAutomatic1111Meta, 3 | parseComfyUiMeta, 4 | parseInvokeAIMeta, 5 | } from 'main/exif'; 6 | import { 7 | automatic1111Prompts, 8 | automatic1111Metadatas, 9 | } from './mocks/automatic1111.mock'; 10 | import { comfyuiMetas, comfyuiPrompts } from './mocks/comfyui.mock'; 11 | import { invokeaiMetas, invokeaiPrompts } from './mocks/invokeai.mock'; 12 | 13 | describe('exif', () => { 14 | it('should parse automatic1111 metadata', () => { 15 | for (let i = 0; i < automatic1111Prompts.length; i++) { 16 | const metadata = parseAutomatic1111Meta(automatic1111Prompts[i]); 17 | 18 | expect(metadata).toEqual(automatic1111Metadatas[i]); 19 | } 20 | }); 21 | 22 | it('should parse comfyui metadata', () => { 23 | for (let i = 0; i < comfyuiPrompts.length; i++) { 24 | const metadata = parseComfyUiMeta(comfyuiPrompts[i]); 25 | 26 | expect(metadata).toEqual(comfyuiMetas[i]); 27 | } 28 | }); 29 | 30 | it('should parse invokeai metadata', () => { 31 | for (let i = 0; i < invokeaiPrompts.length; i++) { 32 | const metadata = parseInvokeAIMeta(invokeaiPrompts[i]); 33 | 34 | expect(metadata).toEqual(invokeaiMetas[i]); 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/utils/mocks/invokeai.mock.ts: -------------------------------------------------------------------------------- 1 | export const invokeaiPrompts = [ 2 | `{"app_version": "3.3.0post3", "generation_mode": "txt2img", "positive_prompt": "landscape", "negative_prompt": "low quality", "width": 768, "height": 432, "seed": 356120443, "rand_device": "cpu", "cfg_scale": 7.5, "steps": 50, "scheduler": "dpmpp_2m_sde_k", "clip_skip": 0, "model": {"model_name": "amixx_3Prunedfp16", "base_model": "sd-1", "model_type": "main"}, "controlnets": [], "ipAdapters": [], "t2iAdapters": [], "loras": [], "vae": {"model_name": "sd-vae-ft-mse", "base_model": "sd-1"}}`, 3 | 4 | `{"app_version": "3.3.0post3", "generation_mode": "txt2img", "positive_prompt": "", "negative_prompt": "low quality", "width": 768, "height": 432, "seed": 3400881859, "rand_device": "cpu", "cfg_scale": 7.5, "steps": 50, "scheduler": "dpmpp_2m_sde_k", "clip_skip": 0, "model": {"model_name": "amixx_3Prunedfp16", "base_model": "sd-1", "model_type": "main"}, "controlnets": [], "ipAdapters": [], "t2iAdapters": [], "loras": [], "vae": {"model_name": "sd-vae-ft-mse", "base_model": "sd-1"}}`, 5 | 6 | `{"app_version": "3.3.0post3", "generation_mode": "txt2img", "positive_prompt": "", "negative_prompt": "", "width": 768, "height": 432, "seed": 2361668801, "rand_device": "cpu", "cfg_scale": 7.5, "steps": 50, "scheduler": "dpmpp_2m_sde_k", "clip_skip": 0, "model": {"model_name": "amixx_3Prunedfp16", "base_model": "sd-1", "model_type": "main"}, "controlnets": [], "ipAdapters": [], "t2iAdapters": [], "loras": [], "vae": {"model_name": "sd-vae-ft-mse", "base_model": "sd-1"}}`, 7 | 8 | `{"app_version": "3.3.0post3", "generation_mode": "txt2img", "positive_prompt": "[Elegant Illustration drawn by Nobuteru Yuki::10], [historical illustration by Yoshikazu Yasuhiko, Haruhiko Mikimoto, Ryoichi Ikegami:8], Eries from The Vision of Escaflowne:, She is the eldest princess of Asturia and the sister of Allen and Millerna. She has long blonde hair and blue eyes, and wears a white dress with a blue cloak. She is diplomatic, graceful, and responsible, but also caring and courageous., retro artstyle, 1990s (style), anime\n", "negative_prompt": "(worst quality)++, (low quality)+", "width": 512, "height": 768, "seed": 3186540132, "rand_device": "cpu", "cfg_scale": 7.5, "steps": 24, "scheduler": "dpmpp_2m_k", "clip_skip": 0, "model": {"model_name": "Journey", "base_model": "sd-1", "model_type": "main"}, "controlnets": [], "ipAdapters": [], "t2iAdapters": [], "loras": [], "vae": {"model_name": "mpaffl", "base_model": "sd-1"}}`, 9 | ]; 10 | 11 | export const invokeaiMetas = [ 12 | { 13 | positivePrompt: 'landscape', 14 | negativePrompt: 'low quality', 15 | cfg: '7.5', 16 | seed: '356120443', 17 | steps: '50', 18 | model: 'amixx_3Prunedfp16', 19 | sampler: 'dpmpp_2m_sde_k', 20 | scheduler: 'dpmpp_2m_sde_k', 21 | generatedBy: 'InvokeAI', 22 | }, 23 | 24 | { 25 | positivePrompt: '', 26 | negativePrompt: 'low quality', 27 | cfg: '7.5', 28 | seed: '3400881859', 29 | steps: '50', 30 | model: 'amixx_3Prunedfp16', 31 | sampler: 'dpmpp_2m_sde_k', 32 | scheduler: 'dpmpp_2m_sde_k', 33 | generatedBy: 'InvokeAI', 34 | }, 35 | 36 | { 37 | positivePrompt: '', 38 | negativePrompt: '', 39 | cfg: '7.5', 40 | seed: '2361668801', 41 | steps: '50', 42 | model: 'amixx_3Prunedfp16', 43 | sampler: 'dpmpp_2m_sde_k', 44 | scheduler: 'dpmpp_2m_sde_k', 45 | generatedBy: 'InvokeAI', 46 | }, 47 | 48 | { 49 | positivePrompt: 50 | '[Elegant Illustration drawn by Nobuteru Yuki::10], [historical illustration by Yoshikazu Yasuhiko, Haruhiko Mikimoto, Ryoichi Ikegami:8], Eries from The Vision of Escaflowne:, She is the eldest princess of Asturia and the sister of Allen and Millerna. She has long blonde hair and blue eyes, and wears a white dress with a blue cloak. She is diplomatic, graceful, and responsible, but also caring and courageous., retro artstyle, 1990s (style), anime', 51 | negativePrompt: '(worst quality)++, (low quality)+', 52 | cfg: '7.5', 53 | seed: '3186540132', 54 | steps: '24', 55 | model: 'Journey', 56 | sampler: 'dpmpp_2m_k', 57 | scheduler: 'dpmpp_2m_k', 58 | generatedBy: 'InvokeAI', 59 | }, 60 | ]; 61 | -------------------------------------------------------------------------------- /src/main/WorkerManagers/HashWorkerManager.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import log from 'electron-log'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { Worker } from 'worker_threads'; 6 | 7 | export default class HashWorkerManager { 8 | // eslint-disable-next-line no-use-before-define 9 | private static instance: HashWorkerManager | null = null; 10 | 11 | private MAX_SIZE = ~~(os.cpus().length / 2); 12 | 13 | private workers: Worker[] = []; 14 | 15 | private activeWorker = 0; 16 | 17 | // eslint-disable-next-line 18 | private constructor() { 19 | console.log('total threads: ', this.MAX_SIZE); 20 | for (let i = 0; i < this.MAX_SIZE; i++) { 21 | const worker = new Worker( 22 | app.isPackaged 23 | ? path.resolve(__dirname, './workers/calculateHash.js') 24 | : path.resolve(__dirname, '../workers/calculateHash.js'), 25 | ); 26 | this.workers.push(worker); 27 | } 28 | } 29 | 30 | public static getInstance(): HashWorkerManager { 31 | if (HashWorkerManager.instance === null) { 32 | HashWorkerManager.instance = new HashWorkerManager(); 33 | } 34 | 35 | return HashWorkerManager.instance; 36 | } 37 | 38 | public size(): number { 39 | return this.workers.length; 40 | } 41 | 42 | private nextWorker() { 43 | if (this.activeWorker >= this.size() - 1) { 44 | this.activeWorker = 0; 45 | } else { 46 | this.activeWorker += 1; 47 | } 48 | } 49 | 50 | public sendMessage(payload: any): Promise { 51 | this.nextWorker(); 52 | return new Promise((resolve, reject) => { 53 | const activeWorker = this.activeWorker; 54 | this.workers[activeWorker].postMessage(payload); 55 | 56 | const listener = (message: { type: string; message: any }) => { 57 | this.workers[activeWorker].removeListener('message', listener); 58 | if (message.type === 'result') { 59 | resolve(message.message); 60 | } else if (message.type === 'error') { 61 | console.error(message.message); 62 | log.error(message.message); 63 | reject(message.message); 64 | } 65 | }; 66 | 67 | this.workers[activeWorker].on('message', listener); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/WorkerManagers/ImageMetadataWorkerManager.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import log from 'electron-log'; 3 | import { app } from 'electron'; 4 | import path from 'path'; 5 | import { Worker } from 'worker_threads'; 6 | import { ImageMetaData } from '../interfaces'; 7 | 8 | export default class ImageMetadataWorkerManager { 9 | // eslint-disable-next-line no-use-before-define 10 | private static instance: ImageMetadataWorkerManager | null = null; 11 | 12 | private MAX_SIZE = ~~(os.cpus().length / 2); 13 | 14 | private workers: Worker[] = []; 15 | 16 | private activeWorker = 0; 17 | 18 | // eslint-disable-next-line 19 | private constructor() { 20 | for (let i = 0; i < this.MAX_SIZE; i++) { 21 | const worker = new Worker( 22 | app.isPackaged 23 | ? path.resolve(__dirname, './workers/imageMetadata.js') 24 | : path.resolve(__dirname, '../workers/imageMetadata.js'), 25 | ); 26 | this.workers.push(worker); 27 | } 28 | } 29 | 30 | public static getInstance(): ImageMetadataWorkerManager { 31 | if (ImageMetadataWorkerManager.instance === null) { 32 | ImageMetadataWorkerManager.instance = new ImageMetadataWorkerManager(); 33 | } 34 | 35 | return ImageMetadataWorkerManager.instance; 36 | } 37 | 38 | public size(): number { 39 | return this.workers.length; 40 | } 41 | 42 | private nextWorker() { 43 | if (this.activeWorker >= this.size() - 1) { 44 | this.activeWorker = 0; 45 | } else { 46 | this.activeWorker += 1; 47 | } 48 | } 49 | 50 | public sendMessage(payload: any): Promise> { 51 | this.nextWorker(); 52 | return new Promise((resolve, reject) => { 53 | const activeWorker = this.activeWorker; 54 | this.workers[activeWorker].postMessage(payload); 55 | 56 | const listener = (message: { type: string; message: any }) => { 57 | this.workers[activeWorker].removeListener('message', listener); 58 | if (message.type === 'result') { 59 | resolve(message.message); 60 | } else if (message.type === 'error') { 61 | console.error(message.message); 62 | log.error(message.message); 63 | reject(message.message); 64 | } 65 | }; 66 | 67 | this.workers[activeWorker].on('message', listener); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/WorkerManagers/ThumbnailWorkerManager.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | import log from 'electron-log'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { Worker } from 'worker_threads'; 6 | 7 | export default class ThumbnailWorkerManager { 8 | // eslint-disable-next-line no-use-before-define 9 | private static instance: ThumbnailWorkerManager | null = null; 10 | 11 | private MAX_SIZE = ~~(os.cpus().length / 2); 12 | 13 | private workers: Worker[] = []; 14 | 15 | private activeWorker = 0; 16 | 17 | // eslint-disable-next-line 18 | private constructor() { 19 | for (let i = 0; i < this.MAX_SIZE; i++) { 20 | const worker = new Worker( 21 | app.isPackaged 22 | ? path.resolve(__dirname, './workers/thumbnails.js') 23 | : path.resolve(__dirname, '../workers/thumbnails.js'), 24 | ); 25 | this.workers.push(worker); 26 | } 27 | } 28 | 29 | public static getInstance(): ThumbnailWorkerManager { 30 | if (ThumbnailWorkerManager.instance === null) { 31 | ThumbnailWorkerManager.instance = new ThumbnailWorkerManager(); 32 | } 33 | 34 | return ThumbnailWorkerManager.instance; 35 | } 36 | 37 | public size(): number { 38 | return this.workers.length; 39 | } 40 | 41 | private nextWorker() { 42 | if (this.activeWorker >= this.size() - 1) { 43 | this.activeWorker = 0; 44 | } else { 45 | this.activeWorker += 1; 46 | } 47 | } 48 | 49 | public sendMessage(payload: any): Promise { 50 | this.nextWorker(); 51 | return new Promise((resolve) => { 52 | const activeWorker = this.activeWorker; 53 | this.workers[activeWorker].postMessage(payload); 54 | 55 | const listener = (message: { type: string; message: any }) => { 56 | this.workers[activeWorker].removeListener('message', listener); 57 | if (message.type === 'result') { 58 | resolve(message.message); 59 | } else if (message.type === 'error') { 60 | console.error(message.message); 61 | log.error(message.message); 62 | resolve(null); 63 | } 64 | }; 65 | 66 | this.workers[activeWorker].on('message', listener); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/WorkerManagers/interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The Observer interface declares the update method, used by subjects. 3 | */ 4 | interface Observer { 5 | // Receive update from subject. 6 | // eslint-disable-next-line 7 | update(subject: Subject): void; 8 | } 9 | 10 | interface Subject { 11 | // Attach an observer to the subject. 12 | attach(observer: Observer): void; 13 | 14 | // Detach an observer from the subject. 15 | detach(observer: Observer): void; 16 | 17 | // Notify all observers about an event. 18 | notify(): void; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/assets/dragAndDropIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/albertpb/sd-manager/fbb29a332e5d91e6821ff9c6670f3a827a75e56e/src/main/assets/dragAndDropIcon.png -------------------------------------------------------------------------------- /src/main/interfaces.ts: -------------------------------------------------------------------------------- 1 | type ModelInfoFile = { 2 | id: number; 3 | url: string; 4 | sizeKB: number; 5 | name: string; 6 | type: string; 7 | metadata: { 8 | fp: string; 9 | size: string; 10 | format: string; 11 | }; 12 | pickleScanResult: string; 13 | pickleScanMessage: string; 14 | virusScanResult: string; 15 | virusScanMessage: string | null; 16 | scannedAt: string; 17 | hashes: { 18 | AutoV1: string; 19 | AutoV2: string; 20 | SHA256: string; 21 | CRC32: string; 22 | BLAKE3: string; 23 | }; 24 | primary: boolean; 25 | downloadUrl: string; 26 | }; 27 | 28 | export type ModelInfoImage = { 29 | url: string; 30 | nsfw: string; 31 | width: number; 32 | height: number; 33 | hash: string; 34 | type: string; 35 | metadata: { 36 | hash: string; 37 | width: number; 38 | height: number; 39 | }; 40 | meta: { 41 | VAE: string; 42 | seed: number; 43 | steps: number; 44 | parser: string; 45 | prompt: string; 46 | sampler: string; 47 | cfgScale: number; 48 | clipSkip: number; 49 | negativePrompt: string; 50 | nsfw: boolean; 51 | }; 52 | }; 53 | 54 | export type ModelCivitaiInfo = { 55 | id: number; 56 | modelId: number; 57 | name: string; 58 | createdAt: string; 59 | updatedAt: string; 60 | trainedWords: string[]; 61 | baseModel: string; 62 | baseModelType: string; 63 | earlyAccessTimeFrame: string; 64 | description: string; 65 | stats: { 66 | downloadCount: number; 67 | ratingCount: number; 68 | rating: number; 69 | }; 70 | model: { 71 | name: string; 72 | type: string; 73 | nsfw: boolean; 74 | poi: boolean; 75 | }; 76 | files: ModelInfoFile[]; 77 | images: ModelInfoImage[]; 78 | downloadUrl: string; 79 | }; 80 | 81 | export type ModelInfo = { 82 | id: number; 83 | name: string; 84 | description: string; 85 | type: string; 86 | poi: boolean; 87 | nsfw: boolean; 88 | allowNoCredit: boolean; 89 | allowCommercialUse: string; 90 | allowDerivatives: boolean; 91 | allowDifferentLicense: boolean; 92 | stats: { 93 | downloadCount: number; 94 | ratingCount: number; 95 | rating: number; 96 | favoriteCount: number; 97 | commentCount: number; 98 | tippedAmountCount: number; 99 | }; 100 | creator: { 101 | username: string; 102 | image: string; 103 | }; 104 | tags: string[]; 105 | modelVersions: ModelCivitaiInfo[]; 106 | }; 107 | 108 | export type ImageMetaData = { 109 | positivePrompt: string; 110 | negativePrompt: string; 111 | sampler: string; 112 | steps: string; 113 | seed: string; 114 | cfg: string; 115 | generatedBy: string; 116 | model: string; 117 | scheduler: string; 118 | }; 119 | -------------------------------------------------------------------------------- /src/main/ipc/fileAttach.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import os from 'os'; 4 | import { IpcMainInvokeEvent } from 'electron'; 5 | import { convertPath } from '../../renderer/utils'; 6 | 7 | export const fileAttach = async ( 8 | event: IpcMainInvokeEvent, 9 | filePath: string, 10 | imageFolder: string, 11 | ) => { 12 | const fileBaseName = path.basename(filePath); 13 | await fs.promises.copyFile( 14 | convertPath(filePath, os.platform()), 15 | convertPath(`${imageFolder}\\${fileBaseName}`, os.platform()), 16 | ); 17 | 18 | return convertPath(`${imageFolder}\\${fileBaseName}`, os.platform()); 19 | }; 20 | -------------------------------------------------------------------------------- /src/main/ipc/fuse.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent, app } from 'electron'; 2 | import os from 'os'; 3 | import fs from 'fs'; 4 | import { convertPath } from '../../renderer/utils'; 5 | 6 | export async function saveFuseIndexIpc( 7 | event: IpcMainInvokeEvent, 8 | fileName: string, 9 | data: any, 10 | ) { 11 | const folderPath = app.getPath('userData'); 12 | 13 | await fs.promises.writeFile( 14 | convertPath(`${folderPath}\\${fileName}`, os.platform()), 15 | JSON.stringify(data), 16 | { 17 | encoding: 'utf-8', 18 | }, 19 | ); 20 | } 21 | 22 | export async function readFuseIndexIpc( 23 | event: IpcMainInvokeEvent, 24 | fileName: string, 25 | ) { 26 | try { 27 | const folderPath = app.getPath('userData'); 28 | 29 | const data = await fs.promises.readFile( 30 | convertPath(`${folderPath}\\${fileName}`, os.platform()), 31 | { 32 | encoding: 'utf-8', 33 | }, 34 | ); 35 | 36 | return JSON.parse(data); 37 | } catch (error) { 38 | console.log(error); 39 | return undefined; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/ipc/getPaths.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | 3 | export const getPathsIpc = async () => { 4 | const documentsPath = app.getPath('documents'); 5 | const appPath = app.getPath('appData'); 6 | 7 | return { 8 | documentsPath, 9 | appPath, 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/main/ipc/metadata.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent } from 'electron'; 2 | import log from 'electron-log/main'; 3 | import fs from 'fs'; 4 | import { extractMetadata } from '../exif'; 5 | import { checkFileExists } from '../util'; 6 | 7 | export const readImageMetadata = async ( 8 | event: IpcMainInvokeEvent, 9 | path: string, 10 | ) => { 11 | try { 12 | const fileExists = await checkFileExists(path); 13 | 14 | if (fileExists) { 15 | const file = await fs.promises.readFile(path); 16 | 17 | const metadata = extractMetadata(file); 18 | const keys = Object.keys(metadata); 19 | for (let i = 0; i < keys.length; i++) { 20 | if (typeof metadata[keys[i]] === 'string') { 21 | try { 22 | metadata[keys[i]] = JSON.parse(metadata[keys[i]]); 23 | } catch (e) { 24 | console.log(`Couldn't parse metadata value, ${path}`); 25 | log.info(`Couldn't parse metadata value, ${path}`); 26 | } 27 | } 28 | } 29 | 30 | return metadata; 31 | } 32 | 33 | return {}; 34 | } catch (error) { 35 | console.log(error); 36 | return {}; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/main/ipc/mtags.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent } from 'electron'; 2 | import log from 'electron-log/main'; 3 | import SqliteDB from '../db'; 4 | 5 | export const mtagIpc = async ( 6 | event: IpcMainInvokeEvent, 7 | action: string, 8 | payload: any, 9 | ) => { 10 | try { 11 | const db = await SqliteDB.getInstance().getdb(); 12 | 13 | switch (action) { 14 | case 'read': { 15 | const mtags = await db.all(`SELECT * FROM mtags`); 16 | return mtags.reduce((acc, tag) => { 17 | acc[tag.id] = tag; 18 | return acc; 19 | }, {}); 20 | } 21 | 22 | case 'add': { 23 | await db.run( 24 | `INSERT INTO mtags (id, label, color, bgColor) VALUES ($id, $label, $color, $bgColor)`, 25 | { 26 | $id: payload.id, 27 | $label: payload.label, 28 | $color: payload.color, 29 | $bgColor: payload.bgColor, 30 | }, 31 | ); 32 | 33 | const activeMTags = await db.get( 34 | `SELECT value FROM settings WHERE key = $key`, 35 | { 36 | $key: 'activeMTags', 37 | }, 38 | ); 39 | 40 | if (activeMTags) { 41 | const activeMTagsArr = 42 | activeMTags.value === '' ? [] : activeMTags.value.split(','); 43 | activeMTagsArr.push(payload.id); 44 | 45 | await db.run(`UPDATE settings SET value = $tagId WHERE key = $key`, { 46 | $tagId: activeMTagsArr.join(','), 47 | $key: 'activeMTags', 48 | }); 49 | } else { 50 | await db.run( 51 | `INSERT INTO settings (key, value) VALUES ($key, $value)`, 52 | { 53 | $key: 'activeMTags', 54 | $value: payload.id, 55 | }, 56 | ); 57 | } 58 | break; 59 | } 60 | 61 | case 'delete': { 62 | const activeMTags = await db.get( 63 | `SELECT value FROM settings WHERE key = $key`, 64 | { 65 | $key: 'activeMTags', 66 | }, 67 | ); 68 | 69 | const activeMTagsArr = 70 | activeMTags !== '' ? activeMTags.value.split(',') : []; 71 | const index = activeMTagsArr.findIndex((t: string) => t === payload.id); 72 | 73 | if (index !== -1) { 74 | activeMTagsArr.splice(index, 1); 75 | 76 | await db.run( 77 | `UPDATE settings SET value = $activeMTags WHERE key = $key`, 78 | { 79 | $activeMTags: activeMTagsArr.join(','), 80 | $key: 'activeMTags', 81 | }, 82 | ); 83 | } 84 | await db.run(`DELETE FROM mtags WHERE id = $id`, { 85 | $id: payload.id, 86 | }); 87 | 88 | break; 89 | } 90 | 91 | case 'edit': { 92 | await db.run( 93 | `UPDATE mtags SET label = $label, color = $color, bgColor = $bgColor WHERE id = $id`, 94 | { 95 | $id: payload.id, 96 | $label: payload.label, 97 | $color: payload.color, 98 | $bgColor: payload.bgColor, 99 | }, 100 | ); 101 | break; 102 | } 103 | 104 | default: { 105 | break; 106 | } 107 | } 108 | 109 | return null; 110 | } catch (error) { 111 | console.error(error); 112 | log.error(error); 113 | return null; 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /src/main/ipc/openLink.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent, shell } from 'electron'; 2 | 3 | export const openLinkIpc = async (event: IpcMainInvokeEvent, url: string) => { 4 | shell.openExternal(url); 5 | }; 6 | 7 | export const openFolderLinkIpc = async ( 8 | event: IpcMainInvokeEvent, 9 | url: string, 10 | ) => { 11 | shell.showItemInFolder(url); 12 | }; 13 | -------------------------------------------------------------------------------- /src/main/ipc/saveMD.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import os from 'os'; 4 | import { IpcMainInvokeEvent, clipboard } from 'electron'; 5 | import { convertPath } from '../../renderer/utils'; 6 | import { checkFolderExists } from '../util'; 7 | 8 | export const saveMDIpc = async ( 9 | event: IpcMainInvokeEvent, 10 | pathDir: string, 11 | textMD: string, 12 | ) => { 13 | const destPathExists = await checkFolderExists(pathDir); 14 | if (!destPathExists) { 15 | await fs.promises.mkdir(pathDir); 16 | } 17 | 18 | await fs.promises.writeFile( 19 | convertPath(`${pathDir}\\markdown.md`, os.platform()), 20 | textMD, 21 | { 22 | encoding: 'utf-8', 23 | }, 24 | ); 25 | }; 26 | 27 | export const saveImageMDIpc = async ( 28 | event: IpcMainInvokeEvent, 29 | source: string, 30 | dest: string, 31 | ) => { 32 | const dir = path.dirname(dest); 33 | const dirExists = await checkFolderExists(dir); 34 | 35 | if (!dirExists) { 36 | await fs.promises.mkdir(dir, { recursive: true }); 37 | } 38 | 39 | await fs.promises.copyFile(source, dest); 40 | }; 41 | 42 | export const saveImageFromClipboardIpc = async ( 43 | event: IpcMainInvokeEvent, 44 | filePath: string, 45 | ) => { 46 | const dir = path.dirname(filePath); 47 | const dirExists = await checkFolderExists(dir); 48 | 49 | if (!dirExists) { 50 | await fs.promises.mkdir(dir, { recursive: true }); 51 | } 52 | 53 | const data = clipboard.readImage('clipboard').toPNG(); 54 | 55 | await fs.promises.writeFile(filePath, data); 56 | }; 57 | -------------------------------------------------------------------------------- /src/main/ipc/settings.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent } from 'electron'; 2 | import log from 'electron-log/main'; 3 | import SqliteDB from '../db'; 4 | 5 | export type StoreAction = 'save' | 'read' | 'readAll'; 6 | 7 | export const settingsDB = async ( 8 | action: StoreAction, 9 | key?: string, 10 | data?: string, 11 | ) => { 12 | try { 13 | const db = await SqliteDB.getInstance().getdb(); 14 | 15 | switch (action) { 16 | case 'save': { 17 | await db.run( 18 | `INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = ? WHERE key = ?`, 19 | { 20 | 1: key, 21 | 2: data, 22 | 3: data, 23 | 4: key, 24 | }, 25 | ); 26 | break; 27 | } 28 | 29 | case 'read': { 30 | return db.get(`SELECT value FROM settings WHERE key = ?`, { 31 | 1: key, 32 | }); 33 | } 34 | 35 | case 'readAll': { 36 | const result = await db.all('SELECT * FROM settings'); 37 | return result.reduce((acc, row) => { 38 | acc[row.key] = row.value; 39 | return acc; 40 | }, {}); 41 | } 42 | 43 | default: { 44 | break; 45 | } 46 | } 47 | 48 | return null; 49 | } catch (error) { 50 | console.error(error); 51 | log.error(error); 52 | return null; 53 | } 54 | }; 55 | 56 | export const settingsIpc = async ( 57 | event: IpcMainInvokeEvent, 58 | action: StoreAction, 59 | key: string, 60 | data?: string, 61 | ) => { 62 | return settingsDB(action, key, data); 63 | }; 64 | -------------------------------------------------------------------------------- /src/main/ipc/tag.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent } from 'electron'; 2 | import log from 'electron-log/main'; 3 | import SqliteDB from '../db'; 4 | 5 | export type Tag = { 6 | id: string; 7 | label: string; 8 | color: string; 9 | bgColor: string; 10 | }; 11 | 12 | export const tagIpc = async ( 13 | event: IpcMainInvokeEvent, 14 | action: string, 15 | payload: any, 16 | ) => { 17 | try { 18 | const db = await SqliteDB.getInstance().getdb(); 19 | 20 | switch (action) { 21 | case 'read': { 22 | const tags = await db.all(`SELECT * FROM tags`); 23 | 24 | return tags.reduce((acc, tag) => { 25 | acc[tag.id] = tag; 26 | return acc; 27 | }, {}); 28 | } 29 | 30 | case 'add': { 31 | await db.run( 32 | `INSERT INTO tags (id, label, color, bgColor) VALUES ($id, $label, $color, $bgColor)`, 33 | { 34 | $id: payload.id, 35 | $label: payload.label, 36 | $color: payload.color, 37 | $bgColor: payload.bgColor, 38 | }, 39 | ); 40 | 41 | const activeTags = await db.get( 42 | `SELECT value FROM settings WHERE key = $key`, 43 | { 44 | $key: 'activeTags', 45 | }, 46 | ); 47 | 48 | if (activeTags) { 49 | const activeMTagsArr = 50 | activeTags.value === '' ? [] : activeTags.value.split(','); 51 | activeMTagsArr.push(payload.id); 52 | 53 | await db.run(`UPDATE settings SET value = $tagId WHERE key = $key`, { 54 | $tagId: activeMTagsArr.join(','), 55 | $key: 'activeTags', 56 | }); 57 | } else { 58 | await db.run( 59 | `INSERT INTO settings (key, value) VALUES ($key, $value)`, 60 | { 61 | $key: 'activeTags', 62 | $value: payload.id, 63 | }, 64 | ); 65 | } 66 | break; 67 | } 68 | 69 | case 'delete': { 70 | const activeTags = await db.get( 71 | `SELECT value FROM settings WHERE key = $key`, 72 | { 73 | $key: 'activeTags', 74 | }, 75 | ); 76 | 77 | const activeTagsArr = 78 | activeTags !== '' ? activeTags.value.split(',') : []; 79 | const index = activeTagsArr.findIndex((t: string) => t === payload.id); 80 | 81 | if (index !== -1) { 82 | activeTagsArr.splice(index, 1); 83 | await db.run( 84 | `UPDATE settings SET value = $activeTags WHERE key = $key`, 85 | { 86 | $activeTags: 87 | activeTagsArr.length === 0 ? '' : activeTagsArr.join(','), 88 | $key: 'activeTags', 89 | }, 90 | ); 91 | } 92 | 93 | await db.run(`DELETE FROM tags WHERE id = $id`, { 94 | $id: payload.id, 95 | }); 96 | 97 | break; 98 | } 99 | 100 | case 'edit': { 101 | await db.run( 102 | `UPDATE tags SET label = $label, color = $color, bgColor = $bgColor WHERE id = $id`, 103 | { 104 | $id: payload.id, 105 | $label: payload.label, 106 | $color: payload.color, 107 | $bgColor: payload.bgColor, 108 | }, 109 | ); 110 | break; 111 | } 112 | 113 | default: { 114 | break; 115 | } 116 | } 117 | 118 | return null; 119 | } catch (error) { 120 | console.error(error); 121 | log.error(error); 122 | return null; 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /src/main/ipc/watchFolders.ts: -------------------------------------------------------------------------------- 1 | import { IpcMainInvokeEvent } from 'electron'; 2 | import log from 'electron-log/main'; 3 | import SqliteDB from '../db'; 4 | 5 | export type WatchFolder = { 6 | path: string; 7 | }; 8 | 9 | export const watchFolderIpc = async ( 10 | event: IpcMainInvokeEvent, 11 | action: string, 12 | payload: any, 13 | ) => { 14 | try { 15 | const db = await SqliteDB.getInstance().getdb(); 16 | 17 | switch (action) { 18 | case 'read': { 19 | return db.all(`SELECT * FROM watch_folders`); 20 | } 21 | 22 | case 'add': { 23 | await db.run(`INSERT INTO watch_folders ('path') VALUES ($path)`, { 24 | $path: payload, 25 | }); 26 | break; 27 | } 28 | 29 | case 'delete': { 30 | if (payload.removeImages) { 31 | await db.run(`DELETE FROM images WHERE path LIKE $path`, { 32 | $path: `${payload.path}%`, 33 | }); 34 | } 35 | 36 | await db.run(`DELETE FROM watch_folders WHERE path = $path`, { 37 | $path: payload.path, 38 | }); 39 | 40 | break; 41 | } 42 | 43 | case 'edit': { 44 | const imagesPaths = await db.all( 45 | `SELECT hash, path, sourcePath FROM images WHERE path LIKE $path`, 46 | { 47 | $path: `${payload.currentPath}%`, 48 | }, 49 | ); 50 | 51 | for (let i = 0; i < imagesPaths.length; i++) { 52 | await db.run( 53 | `UPDATE images SET path = $path, sourcePath = $sourcePath WHERE hash = $hash`, 54 | { 55 | $path: imagesPaths[i].path.replace( 56 | payload.currentPath, 57 | payload.newPath, 58 | ), 59 | $sourcePath: imagesPaths[i].sourcePath.replace( 60 | payload.currentPath, 61 | payload.newPath, 62 | ), 63 | $hash: imagesPaths[i].hash, 64 | }, 65 | ); 66 | } 67 | 68 | await db.run( 69 | `UPDATE watch_folders SET path = $newPath WHERE path = $path`, 70 | { 71 | $newPath: payload.newPath, 72 | $path: payload.currentPath, 73 | }, 74 | ); 75 | break; 76 | } 77 | 78 | default: { 79 | break; 80 | } 81 | } 82 | 83 | return null; 84 | } catch (error) { 85 | console.error(error); 86 | log.error(error); 87 | return null; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /src/main/preload.ts: -------------------------------------------------------------------------------- 1 | // Disable no-unused-vars, broken for spread args 2 | /* eslint no-unused-vars: off */ 3 | import { 4 | IpcRendererEvent, 5 | contextBridge, 6 | ipcRenderer, 7 | webFrame, 8 | } from 'electron'; 9 | 10 | const channels = [ 11 | 'getOS', 12 | 'getImage', 13 | 'getImages', 14 | 'updateImage', 15 | 'removeImages', 16 | 'readdirModels', 17 | 'scanImages', 18 | 'readModels', 19 | 'updateModel', 20 | 'checkModelsToUpdate', 21 | 'readModel', 22 | 'readModelByName', 23 | 'selectDir', 24 | 'settings', 25 | 'readdirModelImages', 26 | 'readModelInfo', 27 | 'readFile', 28 | 'readImage', 29 | 'openLink', 30 | 'openFolderLink', 31 | 'watchImagesFolder', 32 | 'getPaths', 33 | 'fileAttach', 34 | 'saveMD', 35 | 'saveImageMD', 36 | 'saveImageFromClipboard', 37 | 'readImageMetadata', 38 | 'checkModelsToUpdate', 39 | 'watchFolder', 40 | 'tag', 41 | 'tagImage', 42 | 'regenerateThumbnails', 43 | 'mtag', 44 | 'tagModel', 45 | 'removeAllImageTags', 46 | 'removeAllModelsTags', 47 | 'readFuseIndex', 48 | 'saveFuseIndex', 49 | 'getImagesHashByPositivePrompt', 50 | 'getImagesHashByNegativePrompt', 51 | ] as const; 52 | export type Channels = (typeof channels)[number]; 53 | 54 | const ipcHandler: Record Promise> = 55 | channels.reduce((acc: Record, channel) => { 56 | acc[channel] = (...args: any[]) => ipcRenderer.invoke(channel, ...args); 57 | return acc; 58 | }, {}); 59 | 60 | contextBridge.exposeInMainWorld('ipcHandler', ipcHandler); 61 | 62 | contextBridge.exposeInMainWorld('ipcOn', { 63 | modelsProgress: (cb: (event: IpcRendererEvent, ...args: any[]) => any) => { 64 | ipcRenderer.on('models-progress', cb); 65 | return () => ipcRenderer.removeListener('models-progress', cb); 66 | }, 67 | imagesProgress: (cb: (event: IpcRendererEvent, ...args: any[]) => any) => { 68 | ipcRenderer.on('images-progress', cb); 69 | return () => ipcRenderer.removeListener('images-progress', cb); 70 | }, 71 | detectedAddImage: (cb: (event: IpcRendererEvent, ...args: any[]) => any) => { 72 | ipcRenderer.on('detected-add-image', cb); 73 | return () => ipcRenderer.removeListener('detected-add-image', cb); 74 | }, 75 | duplicatesDetected: ( 76 | cb: (event: IpcRendererEvent, ...args: any[]) => any, 77 | ) => { 78 | ipcRenderer.on('duplicates-detected', cb); 79 | return () => ipcRenderer.removeListener('duplicates-detected', cb); 80 | }, 81 | checkingModelUpdate: ( 82 | cb: (event: IpcRendererEvent, ...args: any[]) => any, 83 | ) => { 84 | ipcRenderer.on('checking-model-update', cb); 85 | return () => ipcRenderer.removeListener('checking-model-update', cb); 86 | }, 87 | modelToUpdate: (cb: (event: IpcRendererEvent, ...args: any[]) => any) => { 88 | ipcRenderer.on('model-need-update', cb); 89 | return () => ipcRenderer.removeListener('model-need-update', cb); 90 | }, 91 | startDrag: (fileName: string) => { 92 | ipcRenderer.send('ondragstart', fileName); 93 | }, 94 | }); 95 | 96 | contextBridge.exposeInMainWorld('api', { 97 | clearCache() { 98 | webFrame.clearCache(); 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /src/main/workers/calculateHash.js: -------------------------------------------------------------------------------- 1 | // worker.js 2 | 3 | const { parentPort } = require('worker_threads'); 4 | const hashWasm = require('hash-wasm'); 5 | const crypto = require('crypto'); 6 | const os = require('os'); 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | 10 | function convertPath(inputPath, platform) { 11 | if (platform === 'win32') return inputPath; 12 | 13 | if (platform === 'linux') return inputPath.replace(/\\/g, '/'); 14 | 15 | return inputPath; 16 | } 17 | 18 | async function hashFileBlake3(filePath) { 19 | const parsedPath = path.parse(filePath); 20 | const fileHashOnDiskPath = convertPath( 21 | `${parsedPath.dir}\\${parsedPath.name}.blake3`, 22 | os.platform(), 23 | ); 24 | 25 | if (fs.existsSync(fileHashOnDiskPath)) { 26 | parentPort.postMessage({ 27 | type: 'result', 28 | message: fs.readFileSync(fileHashOnDiskPath, { encoding: 'utf-8' }), 29 | }); 30 | return; 31 | } 32 | 33 | const hash = await hashWasm.createBLAKE3(); 34 | const readStream = fs.createReadStream(filePath, { 35 | highWaterMark: 256 * 1024, 36 | }); 37 | 38 | readStream.on('data', (data) => { 39 | hash.update(data); 40 | }); 41 | 42 | readStream.on('end', () => { 43 | const fileHash = hash.digest('hex'); 44 | fs.writeFileSync(fileHashOnDiskPath, fileHash, { encoding: 'utf-8' }); 45 | parentPort.postMessage({ type: 'result', message: fileHash }); 46 | }); 47 | 48 | readStream.on('error', (error) => { 49 | parentPort.postMessage({ type: 'error', message: error.message }); 50 | }); 51 | } 52 | 53 | async function hashSha256(filePath) { 54 | const parsedPath = path.parse(filePath); 55 | const fileHashOnDiskPath = convertPath( 56 | `${parsedPath.dir}\\${parsedPath.name}.sha256`, 57 | os.platform(), 58 | ); 59 | 60 | if (fs.existsSync(fileHashOnDiskPath)) { 61 | parentPort.postMessage({ 62 | type: 'result', 63 | message: fs.readFileSync(fileHashOnDiskPath, { encoding: 'utf-8' }), 64 | }); 65 | return; 66 | } 67 | 68 | const hash = crypto.createHash('sha256'); 69 | const readStream = fs.createReadStream(filePath); 70 | 71 | readStream.on('data', (data) => { 72 | hash.update(data); 73 | }); 74 | 75 | readStream.on('end', () => { 76 | const fileHash = hash.digest('hex'); 77 | fs.writeFileSync(fileHashOnDiskPath, fileHash, { encoding: 'utf-8' }); 78 | parentPort.postMessage({ type: 'result', message: fileHash }); 79 | }); 80 | 81 | readStream.on('error', (error) => { 82 | parentPort.postMessage({ type: 'error', message: error.message }); 83 | }); 84 | } 85 | 86 | // Listen for messages from the main thread 87 | parentPort.on('message', async ({ algorithm, filePath }) => { 88 | try { 89 | if (algorithm === 'blake3') { 90 | await hashFileBlake3(filePath); 91 | } 92 | if (algorithm === 'sha256') { 93 | await hashSha256(filePath); 94 | } 95 | } catch (error) { 96 | parentPort.postMessage({ type: 'error', message: error.message }); 97 | } 98 | }); 99 | -------------------------------------------------------------------------------- /src/main/workers/thumbnails.js: -------------------------------------------------------------------------------- 1 | // worker.js 2 | 3 | const { parentPort } = require('worker_threads'); 4 | const path = require('path'); 5 | const sharp = require('sharp'); 6 | const fs = require('fs'); 7 | 8 | const processImage = async (imageFile, thumbnailDestPath) => { 9 | try { 10 | const fileExists = fs.existsSync(imageFile); 11 | if (fileExists) { 12 | const basePath = path.dirname(thumbnailDestPath); 13 | const folderExists = fs.existsSync(basePath); 14 | 15 | if (!folderExists) { 16 | fs.mkdirSync(basePath); 17 | } 18 | 19 | await sharp(imageFile) 20 | .resize({ height: 400 }) 21 | .toFormat('webp') 22 | .toFile(thumbnailDestPath); 23 | } 24 | } catch (error) { 25 | console.log(error); 26 | throw error; 27 | } 28 | }; 29 | 30 | // Listen for messages from the main thread 31 | parentPort.on('message', async ({ imageFile, thumbnailDestPath }) => { 32 | try { 33 | await processImage(imageFile, thumbnailDestPath); 34 | parentPort.postMessage({ type: 'result', message: null }); 35 | } catch (error) { 36 | parentPort.postMessage({ type: 'error', message: error.message }); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/main/workers/watcher.js: -------------------------------------------------------------------------------- 1 | // worker.js 2 | 3 | const { parentPort } = require('worker_threads'); 4 | const chokidar = require('chokidar'); 5 | 6 | let watcherImagesFolder = null; 7 | 8 | // Listen for messages from the main thread 9 | parentPort.on('message', async (foldersToWatch) => { 10 | if (watcherImagesFolder !== null) { 11 | watcherImagesFolder.removeAllListeners(); 12 | } 13 | 14 | watcherImagesFolder = chokidar.watch( 15 | foldersToWatch.map((f) => f.path), 16 | { 17 | persistent: true, 18 | ignoreInitial: true, 19 | awaitWriteFinish: true, 20 | }, 21 | ); 22 | 23 | watcherImagesFolder.on('add', async (detectedFile) => { 24 | parentPort.postMessage(detectedFile); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/renderer/App.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import url('../../assets/fonts/montserrat/fonts.scss'); 6 | @import url('../../node_modules/react-multi-carousel/lib/styles.css'); 7 | @import url('../../node_modules/react-toastify/dist/ReactToastify.css'); 8 | 9 | * { 10 | font-family: 'Montserrat', sans-serif; 11 | -webkit-font-smoothing: auto; 12 | } 13 | 14 | .card__figure { 15 | overflow: hidden; 16 | 17 | img { 18 | transition: transform 0.5s ease; 19 | } 20 | } 21 | 22 | .card__figure.animated:hover { 23 | img { 24 | transform: scale(1.1); 25 | } 26 | } 27 | 28 | ::-webkit-scrollbar { 29 | width: 8px; 30 | height: 3px; 31 | } 32 | 33 | ::-webkit-scrollbar-thumb { 34 | height: 50px; 35 | background-color: #666; 36 | border-radius: 3px; 37 | } 38 | 39 | .courtain { 40 | transition: max-height 0.5s ease; 41 | overflow: hidden; 42 | max-height: 1000px; 43 | 44 | &.courtain-hidden { 45 | transition: max-height 0.5s ease; 46 | max-height: 0; 47 | } 48 | } 49 | 50 | .w-md-editor { 51 | --md-editor-font-family: 'Montserrat', Helvetica, Arial, sans-serif !important; 52 | } 53 | 54 | .copy-hover:hover { 55 | .copy-text { 56 | opacity: 1 !important; 57 | } 58 | } 59 | 60 | .pulse { 61 | animation: pulse 0.7s; 62 | } 63 | 64 | @keyframes pulse { 65 | 0% { 66 | box-shadow: 0px 0px 0px 0px rgba(35, 130, 220, 1); 67 | } 68 | 100% { 69 | box-shadow: 0px 0px 5px 25px rgba(35, 130, 220, 0); 70 | } 71 | } 72 | 73 | .navbar { 74 | height: 68px; 75 | } 76 | 77 | .badge { 78 | white-space: nowrap; 79 | } 80 | 81 | input[type='color'] { 82 | width: 25px; 83 | height: 25px; 84 | border-color: #fafafa; 85 | border-width: 2px; 86 | border-radius: 50%; 87 | overflow: hidden; 88 | display: block; 89 | cursor: pointer; 90 | } 91 | input[type='color']::-webkit-color-swatch { 92 | border: none; 93 | border-radius: 50%; 94 | padding: 0; 95 | } 96 | input[type='color']::-webkit-color-swatch-wrapper { 97 | border: none; 98 | border-radius: 50%; 99 | padding: 0; 100 | } 101 | 102 | .noselect { 103 | -webkit-touch-callout: none; /* iOS Safari */ 104 | -webkit-user-select: none; /* Safari */ 105 | -khtml-user-select: none; /* Konqueror HTML */ 106 | -moz-user-select: none; /* Firefox */ 107 | -ms-user-select: none; /* Internet Explorer/Edge */ 108 | user-select: none; /* Non-prefixed version, currently 109 | supported by Chrome and Opera */ 110 | } 111 | -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'jotai'; 2 | import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; 3 | import { AnimatePresence } from 'framer-motion'; 4 | import { store } from './state/index'; 5 | import './App.scss'; 6 | 7 | import MainLayout from './layouts/Main.layout'; 8 | import Settings from './pages/settings'; 9 | import Notificator from './hocs/notification'; 10 | import SettingsLoader from './hocs/settings-loader'; 11 | import ModelsLoader from './hocs/models-loader'; 12 | import ModelDetail from './pages/modelDetail'; 13 | import ImagesLoader from './hocs/images-loader'; 14 | import ImageDetail from './pages/imageDetail'; 15 | import AspectRatioHelper from './pages/aspectRatioHelper'; 16 | import Images from './pages/images'; 17 | import ImageMetadata from './pages/imageMetadata'; 18 | import Loras from './pages/loras'; 19 | import Checkpoints from './pages/checkpoints'; 20 | import TestPage from './pages/testing'; 21 | import ModelImageDetail from './pages/modelImageDetail'; 22 | import { DetectOs } from './hocs/detect-os'; 23 | 24 | export default function App() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | }> 36 | } /> 37 | } /> 38 | 39 | } /> 40 | } 43 | /> 44 | 45 | } 48 | /> 49 | } /> 50 | } /> 51 | } 54 | /> 55 | } 58 | /> 59 | } /> 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/renderer/components/BadgeCommaTexts.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useState } from 'react'; 3 | 4 | export default function BadgeCopyWords({ words }: { words: string[] }) { 5 | const [classEffect, setClassEffect] = useState(''); 6 | const [index, setIndex] = useState(-1); 7 | 8 | const onClick = (word: string, i: number) => { 9 | navigator.clipboard.writeText(word); 10 | setIndex(i); 11 | setClassEffect('pulse'); 12 | setTimeout(() => { 13 | setClassEffect(''); 14 | setIndex(-1); 15 | }, 700); 16 | }; 17 | 18 | return words.map((word, i) => { 19 | return ( 20 |
onClick(word, i)} 27 | aria-hidden 28 | > 29 | {word}{' '} 30 | 31 | 39 | 44 | 45 | 46 |
47 | ); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | export default function ColorPicker({ 2 | color, 3 | onChange, 4 | className, 5 | }: { 6 | color: string; 7 | onChange: (color: string) => void; 8 | className?: string; 9 | }) { 10 | return ( 11 |
12 | onChange(e.target.value)} 16 | /> 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/components/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | export type ConfirmDialogResponse = { 4 | type: string; 5 | value: any; 6 | }; 7 | 8 | interface ConfirmDialogProps { 9 | response: ConfirmDialogResponse; 10 | isOpen: boolean; 11 | onClose: (response: ConfirmDialogResponse) => void; 12 | onConfirm: (response: ConfirmDialogResponse) => void; 13 | msg: string; 14 | } 15 | 16 | export default function ConfirmDialog({ 17 | response, 18 | isOpen, 19 | onClose, 20 | onConfirm, 21 | msg, 22 | }: ConfirmDialogProps) { 23 | return ( 24 | 32 |
33 |

Confirm

34 |

{msg}

35 |
36 |
37 | 44 | 51 |
52 |
53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/renderer/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { AnimatePresence, motion } from 'framer-motion'; 3 | import { useState, useEffect, useRef } from 'react'; 4 | 5 | type ContextMenuProps = { 6 | id: string; 7 | containerId: string; 8 | isOpen: boolean; 9 | onClose: () => void; 10 | onOpen: () => void; 11 | children: any; 12 | }; 13 | 14 | export default function ContextMenu({ 15 | id, 16 | containerId, 17 | isOpen, 18 | onClose, 19 | onOpen, 20 | children, 21 | }: ContextMenuProps) { 22 | const [coords, setCoords] = useState<{ x: number; y: number }>({ 23 | x: 0, 24 | y: 0, 25 | }); 26 | const menuRef = useRef(null); 27 | 28 | useEffect(() => { 29 | const contextMenuCb = (e: MouseEvent) => { 30 | if (menuRef.current) { 31 | e.preventDefault(); 32 | 33 | let x = e.clientX; 34 | let y = e.clientY; 35 | if (e.clientX + 225 > window.innerWidth) { 36 | x -= 225; 37 | } 38 | if (e.clientY + 200 > window.innerHeight) { 39 | y -= 200; 40 | } 41 | 42 | setCoords({ 43 | x, 44 | y, 45 | }); 46 | 47 | onOpen(); 48 | } 49 | }; 50 | 51 | const container = document.getElementById(containerId); 52 | container?.addEventListener('contextmenu', contextMenuCb); 53 | 54 | return () => container?.removeEventListener('contextmenu', contextMenuCb); 55 | }, [containerId, onOpen]); 56 | 57 | useEffect(() => { 58 | const clickCb = (e: MouseEvent) => { 59 | if (menuRef.current && !menuRef.current.contains(e.target as Node)) { 60 | onClose(); 61 | } 62 | }; 63 | 64 | document.addEventListener('click', clickCb); 65 | 66 | return () => document.removeEventListener('click', clickCb); 67 | }, [onClose]); 68 | 69 | return ( 70 | 71 |
72 | {isOpen && ( 73 | 93 | {children} 94 | 95 | )} 96 |
97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/renderer/components/CopyText.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useState } from 'react'; 3 | 4 | export default function CopyText({ children }: { children: string }) { 5 | const [classEffect, setClassEffect] = useState(''); 6 | 7 | const onClick = () => { 8 | navigator.clipboard.writeText(children); 9 | setClassEffect('pulse'); 10 | setTimeout(() => { 11 | setClassEffect(''); 12 | }, 700); 13 | }; 14 | 15 | return ( 16 |
onClick()} 19 | className={classNames( 20 | 'copy-hover hover:backdrop-brightness-50 cursor-pointer flex justify-between items-center px-5 py-2', 21 | classEffect, 22 | )} 23 | > 24 |

{children}

25 | 26 | 42 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/components/Exif.tsx: -------------------------------------------------------------------------------- 1 | import CodeMirror from '@uiw/react-codemirror'; 2 | import { json as jsonLang } from '@codemirror/lang-json'; 3 | import classNames from 'classnames'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | export default function ExifJson({ 7 | exifParams, 8 | }: { 9 | exifParams: Record | null; 10 | }) { 11 | const [height, setHeight] = useState(window.innerHeight - 250); 12 | const [tab, setTab] = useState('prompt'); 13 | 14 | useEffect(() => { 15 | const onResize = () => { 16 | setHeight(window.innerHeight - 250); 17 | }; 18 | 19 | window.addEventListener('resize', onResize); 20 | 21 | return () => window.removeEventListener('resize', onResize); 22 | }, []); 23 | 24 | if (exifParams === null) return null; 25 | 26 | return ( 27 |
28 |
29 | 36 | 43 | 52 |
53 | 54 | {exifParams.prompt && tab === 'prompt' && ( 55 |
56 |

Prompt

57 |
58 | 64 |
65 |
66 | )} 67 | {exifParams.workflow && tab === 'workflow' && ( 68 |
69 |

Workflow

70 | 76 |
77 | )} 78 | {exifParams.parameters && tab === 'parameters' && ( 79 |
80 |

Parameters

81 |
{exifParams.parameters}
82 |
83 | )} 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/renderer/components/FullLoader.tsx: -------------------------------------------------------------------------------- 1 | export function FullLoader({ 2 | title, 3 | progress, 4 | message, 5 | }: { 6 | title?: string; 7 | progress?: number; 8 | message?: string; 9 | }) { 10 | return ( 11 |
12 |
13 |
14 |
{title}
15 | {progress !== undefined ? ( 16 |
{progress.toFixed(2)}%
17 | ) : ( 18 | 19 | )} 20 | {message &&
{message}
} 21 |
22 |
23 | {progress !== undefined && ( 24 | 25 | )} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, forwardRef, DragEvent } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import classNames from 'classnames'; 4 | import placeHolderImage from '../../../assets/images/notavailable.png'; 5 | 6 | type ImageProps = { 7 | src: string; 8 | alt: string; 9 | width?: number | string; 10 | height?: number | string; 11 | className?: string; 12 | onDragPath?: string; 13 | }; 14 | 15 | export default forwardRef( 16 | ( 17 | { src, alt, width, height, className, onDragPath }: ImageProps, 18 | ref: ForwardedRef, 19 | ) => { 20 | const ondragstart = (event: DragEvent) => { 21 | event.preventDefault(); 22 | if (onDragPath) { 23 | window.ipcOn.startDrag(onDragPath); 24 | } 25 | }; 26 | 27 | return ( 28 | 38 | {alt} { 43 | event.currentTarget.onerror = null; 44 | event.currentTarget.src = placeHolderImage; 45 | }} 46 | width={width} 47 | height={height} 48 | draggable={onDragPath !== undefined} 49 | onDragStart={(event) => ondragstart(event)} 50 | style={{ 51 | width: typeof width === 'number' ? `${width}px` : width, 52 | height: typeof height === 'number' ? `${height}px` : height, 53 | }} 54 | className={classNames(className)} 55 | /> 56 | 57 | ); 58 | }, 59 | ); 60 | -------------------------------------------------------------------------------- /src/renderer/components/ImageMetadata.tsx: -------------------------------------------------------------------------------- 1 | import { ImageMetaData } from '../../main/interfaces'; 2 | import CopyText from './CopyText'; 3 | 4 | export default function ImageMegadata({ 5 | metadata, 6 | }: { 7 | metadata: ImageMetaData | null; 8 | }) { 9 | if (!metadata) return null; 10 | 11 | return ( 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 29 | 30 | 31 | 34 | 37 | 38 | 39 | 42 | 45 | 46 | 47 | 50 | 53 | 54 | 55 | 58 | 61 | 62 | 63 | 66 | 69 | 70 | 71 | 74 | 77 | 78 | 79 | 82 | 85 | 86 | 87 |
16 |

Details:

17 |
24 |

Prompt

25 |
27 | {metadata.positivePrompt || ''} 28 |
32 |

Negative Prompt

33 |
35 | {metadata.negativePrompt || ''} 36 |
40 |

Sampler

41 |
43 | {metadata.sampler || ''} 44 |
48 |

Scheduler

49 |
51 | {metadata.scheduler} 52 |
56 |

Steps

57 |
59 | {metadata.steps} 60 |
64 |

Seed

65 |
67 | {metadata.seed} 68 |
72 |

CFG

73 |
75 | {metadata.cfg} 76 |
80 |

Generated by

81 |
83 | {metadata.generatedBy} 84 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/renderer/components/ImageZoom.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { MouseEvent, createRef, useCallback } from 'react'; 3 | import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; 4 | import Image from './Image'; 5 | 6 | type ImageZoomProps = { 7 | src: string; 8 | alt: string; 9 | className?: any; 10 | imgClassName?: any; 11 | width?: number | string; 12 | height?: number | string; 13 | onClick?: (e: MouseEvent) => void; 14 | }; 15 | 16 | export default function ImageZoom({ 17 | src, 18 | alt, 19 | className, 20 | imgClassName, 21 | width, 22 | height, 23 | onClick, 24 | }: ImageZoomProps) { 25 | const imgRef = createRef(); 26 | const onUpdate = useCallback( 27 | ({ x, y, scale }: { x: number; y: number; scale: number }) => { 28 | const { current: img } = imgRef; 29 | 30 | if (img) { 31 | const value = make3dTransformValue({ x, y, scale }); 32 | 33 | img.style.setProperty('transform', value); 34 | } 35 | }, 36 | [imgRef], 37 | ); 38 | 39 | const ondragstart = () => { 40 | window.ipcOn.startDrag(src); 41 | }; 42 | 43 | return ( 44 |
onClick && onClick(e)} 48 | aria-hidden 49 | > 50 | ondragstart()} 54 | > 55 | {alt} 63 | 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/components/ModelCard.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent } from 'react'; 2 | import classNames from 'classnames'; 3 | import { Tag } from 'main/ipc/tag'; 4 | import Image from './Image'; 5 | import Rating from './Rating'; 6 | 7 | type ModelCardProps = { 8 | id: string; 9 | imagePath: string; 10 | name: string; 11 | width?: string; 12 | height?: string; 13 | rating?: number; 14 | imageClassName?: any; 15 | figureClassName?: any; 16 | type: string; 17 | loading?: boolean; 18 | needUpdate?: boolean; 19 | className?: any; 20 | showRating?: boolean; 21 | hoverEffect?: boolean; 22 | showName?: boolean; 23 | showBadge?: boolean; 24 | onDragPath?: string; 25 | onRatingChange?: (event: MouseEvent, value: number) => void; 26 | tags?: Tag[]; 27 | onClick: (e: MouseEvent) => void; 28 | }; 29 | 30 | export default function ModelCard({ 31 | id, 32 | imagePath, 33 | name, 34 | rating, 35 | width = '480px', 36 | height = '320px', 37 | imageClassName = 'object-cover', 38 | figureClassName = '', 39 | className = '', 40 | type, 41 | loading = false, 42 | needUpdate = false, 43 | showRating = true, 44 | hoverEffect = true, 45 | showName = true, 46 | showBadge = true, 47 | onDragPath, 48 | onRatingChange, 49 | tags, 50 | onClick, 51 | }: ModelCardProps) { 52 | const loadingOverlay = ( 53 |
54 | 55 |
Checking . . .
56 |
57 | ); 58 | 59 | return ( 60 |
onClick(e)} 66 | aria-hidden 67 | > 68 | {loading && loadingOverlay} 69 |
78 | {name} 86 |
87 |
88 |
89 | {showBadge && ( 90 | <> 91 |
{type}
92 | {tags?.map((tag) => ( 93 |
101 | {tag?.label} 102 |
103 | ))} 104 | 105 | )} 106 | {needUpdate && ( 107 |
update
108 | )} 109 |
110 | {showRating && ( 111 |
112 | 117 |
118 | )} 119 |
120 |
121 | {showName && ( 122 |
123 |

{name}

124 |
125 | )} 126 |
127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /src/renderer/components/ModelTableDetail.tsx: -------------------------------------------------------------------------------- 1 | import { ModelCivitaiInfo } from 'main/interfaces'; 2 | import BadgeCopyWords from './BadgeCommaTexts'; 3 | 4 | export type ModelTableDetailProps = { 5 | modelInfo: ModelCivitaiInfo; 6 | }; 7 | 8 | export default function ModelTableDetail({ modelInfo }: ModelTableDetailProps) { 9 | const openCivitaiLink = () => { 10 | window.ipcHandler.openLink( 11 | `https://civitai.com/models/${modelInfo.modelId}`, 12 | ); 13 | }; 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 35 | 36 | 37 | 38 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 60 | 61 | 62 | 63 | 72 | 73 | 74 |
Details
Name 25 |
{modelInfo?.name}
26 |
Type 31 |
32 | {modelInfo?.model?.type} 33 |
34 |
Trained words 39 |
40 | {modelInfo?.trainedWords?.map((words, i) => ( 41 | w.trim() !== '')} 44 | /> 45 | ))} 46 |
47 |
Base Model 52 |
{modelInfo?.baseModel}
53 |
Model Id 58 |
{modelInfo?.modelId}
59 |
Link 64 | 71 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/renderer/components/MultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardEvent, MouseEvent } from 'react'; 2 | import ReactTwSelect from 'react-tailwindcss-select'; 3 | import { 4 | SelectValue, 5 | Option, 6 | } from 'react-tailwindcss-select/dist/components/type'; 7 | 8 | export default function MultiSelect({ 9 | value, 10 | options, 11 | onChange, 12 | isSearchable, 13 | }: { 14 | value: Option[]; 15 | options: Option[]; 16 | onChange: ( 17 | e: MouseEvent | KeyboardEvent, 18 | value: SelectValue, 19 | ) => void; 20 | isSearchable?: boolean; 21 | }) { 22 | return ( 23 | 26 | `flex text-sm border rounded-xl border-base-content bg-base-100 border-opacity-20 shadow-sm transition-all duration-300 focus:outline-none ${ 27 | v?.isDisabled 28 | ? 'bg-neutral' 29 | : 'bg-base-100 focus:border-blue-500 focus:ring focus:ring-blue-500/20' 30 | }`, 31 | menu: 'absolute z-10 w-full bg-base-100 shadow-sm rounded-xl border border-base-content border-opacity-20 py-1 mt-1.5 text-sm text-gray-700', 32 | listItem: (v) => 33 | `bg-base-100 block transition duration-200 py-2 cursor-pointer select-none truncate rounded ${ 34 | v?.isSelected 35 | ? `bg-neutral text-white` 36 | : `hover:bg-neutral-focus text-accent hover:text-secondary-500` 37 | }`, 38 | list: 'bg-base-100 mx-2', 39 | ChevronIcon: (v) => `text-base-content ${v?.open ? `` : ``}`, 40 | tagItem: (v) => { 41 | let className = 'badge badge-outline badge-primary'; 42 | if (v?.isDisabled || v?.item?.disabled) { 43 | className += 'badge-neutral'; 44 | } 45 | 46 | return className; 47 | }, 48 | tagItemIcon: 'w-3 h-3 mt-0.5 outline-primary', 49 | tagItemIconContainer: 50 | 'flex items-center px-1 cursor-pointer rounded-r-sm hover:text-accent', 51 | tagItemText: 'text-primary', 52 | searchBox: 53 | 'w-full py-2 pl-8 text-sm text-gray-500 bg-base-100 rounded-xl border border-base-content rounded border-opacity-20 focus:ring-0 focus:outline-none', 54 | }} 55 | options={options} 56 | value={value} 57 | onChange={( 58 | e: MouseEvent | KeyboardEvent, 59 | v: SelectValue, 60 | ) => onChange(e, v)} 61 | primaryColor="blue" 62 | isSearchable={isSearchable} 63 | isMultiple 64 | /> 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useLocation, Link } from 'react-router-dom'; 3 | import useTab, { tabs } from 'renderer/hooks/tabs'; 4 | import { useEffect, useRef, useState } from 'react'; 5 | import { useAtom } from 'jotai'; 6 | import { navbarAtom } from 'renderer/state/navbar.store'; 7 | import { checkpointsAtom } from 'renderer/state/models.store'; 8 | import { deleteImages, imagesAtom } from 'renderer/state/images.store'; 9 | import ConfirmDialog from './ConfirmDialog'; 10 | 11 | export default function Navbar() { 12 | const location = useLocation(); 13 | const currentTab = useTab(); 14 | const searchRef = useRef(null); 15 | 16 | const [navbarState, setNavbarState] = useAtom(navbarAtom); 17 | const [imagesState] = useAtom(imagesAtom); 18 | const [checkpointsState] = useAtom(checkpointsAtom); 19 | 20 | const [cofirmDialogIsOpen, setConfirmDialogIsOpen] = useState(false); 21 | 22 | useEffect(() => { 23 | const keyListener = (event: KeyboardEvent) => { 24 | if (event.ctrlKey && event.key === 'f') { 25 | if (location.pathname !== '/image-metadata') { 26 | if (searchRef.current !== null) { 27 | searchRef.current.focus(); 28 | } 29 | } 30 | } 31 | }; 32 | 33 | document.addEventListener('keydown', keyListener); 34 | 35 | return () => { 36 | document.removeEventListener('keydown', keyListener); 37 | }; 38 | }, [location.pathname]); 39 | 40 | const onDeleteImages = async () => { 41 | await deleteImages(); 42 | setConfirmDialogIsOpen(false); 43 | }; 44 | 45 | const changeFilterCheckpoint = (checkpointName: string) => { 46 | setNavbarState((draft) => { 47 | draft.filterCheckpoint = checkpointName; 48 | }); 49 | }; 50 | 51 | const imagesToDeleteButton = 52 | Object.values(imagesState.toDelete).length > 0 && !navbarState.disabled ? ( 53 | 74 | ) : null; 75 | 76 | const tabsList = Object.values(tabs).map((t) => ( 77 |
  • 78 | { 85 | if (navbarState.disabled) e.preventDefault(); 86 | }} 87 | > 88 | {t.label} 89 | 90 |
  • 91 | )); 92 | 93 | return ( 94 | <> 95 | 208 | setConfirmDialogIsOpen(false)} 213 | onConfirm={() => onDeleteImages()} 214 | /> 215 | 216 | ); 217 | } 218 | -------------------------------------------------------------------------------- /src/renderer/components/Rating.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { MouseEvent } from 'react'; 3 | 4 | export default function Rating({ 5 | id, 6 | value, 7 | onClick, 8 | hidden, 9 | }: { 10 | id: string; 11 | value: number; 12 | onClick?: (event: MouseEvent, value: number) => void; 13 | hidden?: boolean; 14 | }) { 15 | const click = (e: MouseEvent, v: number) => { 16 | const target = e.target as HTMLInputElement; 17 | target.blur(); 18 | if (onClick) { 19 | onClick(e, v); 20 | } 21 | }; 22 | 23 | const stars = ( 24 | <> 25 | click(e, 1)} 31 | onChange={() => {}} 32 | /> 33 | click(e, 2)} 39 | onChange={() => {}} 40 | /> 41 | click(e, 3)} 47 | onChange={() => {}} 48 | /> 49 | click(e, 4)} 55 | onChange={() => {}} 56 | /> 57 | click(e, 5)} 63 | onChange={() => {}} 64 | /> 65 | 66 | ); 67 | 68 | return ( 69 |
    77 | {stars} 78 |
    79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/renderer/components/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import { imagesAtom } from 'renderer/state/images.store'; 3 | import { ImportProgress } from 'renderer/state/interfaces'; 4 | import { checkpointsAtom, lorasAtom } from 'renderer/state/models.store'; 5 | 6 | type StatusBarProps = { 7 | filteredCards?: number; 8 | totalCards?: number; 9 | checkpointsImportProgress: ImportProgress; 10 | lorasImportProgress: ImportProgress; 11 | imagesImportProgress: ImportProgress; 12 | }; 13 | 14 | export default function StatusBar({ 15 | filteredCards, 16 | totalCards, 17 | checkpointsImportProgress, 18 | lorasImportProgress, 19 | imagesImportProgress, 20 | }: StatusBarProps) { 21 | const [checkpointsState] = useAtom(checkpointsAtom); 22 | const [lorasState] = useAtom(lorasAtom); 23 | const [imagesState] = useAtom(imagesAtom); 24 | 25 | return ( 26 |
      30 |
      31 | {checkpointsState.loading && ( 32 |
      33 | 34 | Loading checkpoints: {checkpointsImportProgress.message}{' '} 35 | {checkpointsImportProgress.progress}% 36 | 37 | 42 |
      43 | )} 44 | {lorasState.loading && ( 45 |
      46 | 47 | Loading loras: {lorasImportProgress.message}{' '} 48 | {lorasImportProgress.progress.toFixed(2)}% 49 | 50 | 55 |
      56 | )} 57 | {imagesState.loading && ( 58 |
      59 | 60 | Loading images: {imagesImportProgress.message}{' '} 61 | {imagesImportProgress.progress.toFixed(2)}% 62 | 63 | 68 |
      69 | )} 70 | {filteredCards !== undefined && ( 71 | 89 | )} 90 | {totalCards !== undefined && ( 91 | 108 | )} 109 |
      110 |
    111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/renderer/components/UpDownButton.tsx: -------------------------------------------------------------------------------- 1 | export default function UpDownButton({ 2 | up, 3 | onClick, 4 | }: { 5 | up: boolean; 6 | onClick?: (up: boolean) => void; 7 | }) { 8 | return ( 9 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/fuzzy/images.fuse.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js'; 2 | import { ImageRow } from 'main/ipc/image'; 3 | import { ImageWithTags } from 'renderer/state/interfaces'; 4 | 5 | export default class ImagesFuseSingleton { 6 | FUSE_IMAGES_BY_TAGS_FILENAME = 'images-bytags-fuse.json'; 7 | 8 | FUSE_IMAGES_BY_MODEL_FILENAME = 'images-bymodel-fuse.json'; 9 | 10 | fuseByTags: Fuse | undefined; 11 | 12 | fuseByModel: Fuse | undefined; 13 | 14 | // eslint-disable-next-line no-use-before-define 15 | private static instance: ImagesFuseSingleton | null = null; 16 | 17 | static getInstance(): ImagesFuseSingleton { 18 | if (!ImagesFuseSingleton.instance) { 19 | ImagesFuseSingleton.instance = new ImagesFuseSingleton(); 20 | } 21 | 22 | return ImagesFuseSingleton.instance; 23 | } 24 | 25 | async initFuseByTags(images: ImageWithTags[]) { 26 | const indexes: string | undefined = await window.ipcHandler.readFuseIndex( 27 | this.FUSE_IMAGES_BY_TAGS_FILENAME, 28 | ); 29 | 30 | this.fuseByTags = new Fuse( 31 | images, 32 | { 33 | keys: ['tags.label'], 34 | }, 35 | indexes ? Fuse.parseIndex(indexes) : undefined, 36 | ); 37 | } 38 | 39 | async initFuseByModel(images: ImageWithTags[]) { 40 | const indexes: string | undefined = await window.ipcHandler.readFuseIndex( 41 | this.FUSE_IMAGES_BY_MODEL_FILENAME, 42 | ); 43 | 44 | this.fuseByModel = new Fuse( 45 | images, 46 | { 47 | keys: ['model'], 48 | }, 49 | indexes ? Fuse.parseIndex(indexes) : undefined, 50 | ); 51 | } 52 | 53 | async saveFuseIndexes() { 54 | if (this.fuseByModel) { 55 | await window.ipcHandler.saveFuseIndex( 56 | this.FUSE_IMAGES_BY_MODEL_FILENAME, 57 | this.fuseByModel.getIndex().toJSON(), 58 | ); 59 | } 60 | 61 | if (this.fuseByTags) { 62 | await window.ipcHandler.saveFuseIndex( 63 | this.FUSE_IMAGES_BY_TAGS_FILENAME, 64 | this.fuseByTags.getIndex().toJSON(), 65 | ); 66 | } 67 | } 68 | 69 | removeImages(imagesToDelete: (ImageWithTags | ImageRow)[]) { 70 | imagesToDelete.forEach((img) => { 71 | if (this.fuseByModel) { 72 | this.fuseByModel.remove((doc) => doc.hash === img.hash); 73 | } 74 | if (this.fuseByTags) { 75 | this.fuseByTags.remove((doc) => doc.hash === img.hash); 76 | } 77 | }); 78 | } 79 | 80 | getFuseByTags() { 81 | return this.fuseByTags; 82 | } 83 | 84 | getFuseByModel() { 85 | return this.fuseByModel; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/renderer/fuzzy/models.fuse.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js'; 2 | import { ModelWithTags } from 'renderer/state/interfaces'; 3 | 4 | export default class ModelsFuseSingleton { 5 | FUSE_MODELS_LORA_FILENAME = 'models-lora-fuse.json'; 6 | 7 | FUSE_MODELS_CHECKPOINT_FILENAME = 'models-checkpoint-fuse.json'; 8 | 9 | fuseCheckpoint: Fuse | undefined; 10 | 11 | fuseLora: Fuse | undefined; 12 | 13 | // eslint-disable-next-line no-use-before-define 14 | private static instance: ModelsFuseSingleton | null = null; 15 | 16 | static getInstance(): ModelsFuseSingleton { 17 | if (!ModelsFuseSingleton.instance) { 18 | ModelsFuseSingleton.instance = new ModelsFuseSingleton(); 19 | } 20 | 21 | return ModelsFuseSingleton.instance; 22 | } 23 | 24 | async initFuseCheckpoint(models: ModelWithTags[]) { 25 | const indexes: string | undefined = await window.ipcHandler.readFuseIndex( 26 | this.FUSE_MODELS_CHECKPOINT_FILENAME, 27 | ); 28 | 29 | this.fuseCheckpoint = new Fuse( 30 | models, 31 | { 32 | keys: ['name'], 33 | }, 34 | indexes ? Fuse.parseIndex(indexes) : undefined, 35 | ); 36 | } 37 | 38 | async initFuseLora(models: ModelWithTags[]) { 39 | const indexes: string | undefined = await window.ipcHandler.readFuseIndex( 40 | this.FUSE_MODELS_LORA_FILENAME, 41 | ); 42 | 43 | this.fuseLora = new Fuse( 44 | models, 45 | { 46 | keys: ['name'], 47 | }, 48 | indexes ? Fuse.parseIndex(indexes) : undefined, 49 | ); 50 | } 51 | 52 | async saveFuseIndexes() { 53 | if (this.fuseCheckpoint) { 54 | await window.ipcHandler.saveFuseIndex( 55 | this.FUSE_MODELS_CHECKPOINT_FILENAME, 56 | this.fuseCheckpoint.getIndex().toJSON(), 57 | ); 58 | } 59 | 60 | if (this.fuseLora) { 61 | await window.ipcHandler.saveFuseIndex( 62 | this.FUSE_MODELS_LORA_FILENAME, 63 | this.fuseLora.getIndex().toJSON(), 64 | ); 65 | } 66 | } 67 | 68 | getFuseCheckpoint() { 69 | return this.fuseCheckpoint; 70 | } 71 | 72 | getFuseLora() { 73 | return this.fuseLora; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/renderer/hocs/detect-os.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useEffect, useState } from 'react'; 2 | 3 | export const OsContext = createContext(''); 4 | 5 | export function DetectOs({ children }: { children: ReactNode }) { 6 | const [os, setOs] = useState(''); 7 | 8 | useEffect(() => { 9 | async function getOs() { 10 | const result = await window.ipcHandler.getOS(); 11 | setOs(result); 12 | } 13 | if (!os) { 14 | getOs(); 15 | } 16 | }, [os]); 17 | 18 | if (!os) return null; 19 | 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /src/renderer/hocs/images-loader.tsx: -------------------------------------------------------------------------------- 1 | import { IpcRendererEvent } from 'electron'; 2 | import { ImageRow } from 'main/ipc/image'; 3 | import { ReactNode, useEffect } from 'react'; 4 | import { WatchFolder } from 'main/ipc/watchFolders'; 5 | import { useNavigate } from 'react-router-dom'; 6 | import { 7 | imagesAtom, 8 | loadImages, 9 | loadImagesTags, 10 | loadWatchFolders, 11 | scanImages, 12 | } from 'renderer/state/images.store'; 13 | import { useAtom } from 'jotai'; 14 | import { settingsAtom } from 'renderer/state/settings.store'; 15 | 16 | export default function ImagesLoader({ children }: { children: ReactNode }) { 17 | const navigate = useNavigate(); 18 | 19 | const [settingsState] = useAtom(settingsAtom); 20 | const [, setImagesState] = useAtom(imagesAtom); 21 | 22 | useEffect(() => { 23 | const load = async () => { 24 | await loadWatchFolders(); 25 | await loadImagesTags(); 26 | }; 27 | load(); 28 | }, []); 29 | 30 | useEffect(() => { 31 | const load = async () => { 32 | console.log('load images hoc'); 33 | const wfs: WatchFolder[] = await window.ipcHandler.watchFolder('read'); 34 | if (settingsState.scanImagesOnStart === '1') { 35 | await scanImages(wfs.map((f) => f.path)); 36 | } 37 | if (wfs.length > 0) { 38 | await loadImages(); 39 | } else { 40 | navigate('/settings'); 41 | } 42 | }; 43 | load(); 44 | // eslint-disable-next-line 45 | }, [settingsState]); 46 | 47 | useEffect(() => { 48 | const cb = (event: IpcRendererEvent, m: string, p: number) => { 49 | setImagesState((draft) => { 50 | draft.importProgress = { 51 | progress: p, 52 | message: m, 53 | }; 54 | }); 55 | }; 56 | const remove = window.ipcOn.imagesProgress(cb); 57 | 58 | return () => remove(); 59 | }, [setImagesState]); 60 | 61 | useEffect(() => { 62 | const cb = (event: IpcRendererEvent, imagesData: ImageRow) => { 63 | setImagesState((draft) => { 64 | draft.images = [imagesData, ...draft.images]; 65 | }); 66 | }; 67 | 68 | const remove = window.ipcOn.detectedAddImage(cb); 69 | 70 | return () => remove(); 71 | }, [setImagesState]); 72 | 73 | return children; 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/hocs/models-loader.tsx: -------------------------------------------------------------------------------- 1 | import { IpcRendererEvent } from 'electron'; 2 | import { useAtom } from 'jotai'; 3 | import { ModelType } from 'main/ipc/model'; 4 | import { ReactNode, useEffect } from 'react'; 5 | import { 6 | checkpointsAtom, 7 | loadModels, 8 | loadModelsTags, 9 | lorasAtom, 10 | } from 'renderer/state/models.store'; 11 | import { settingsAtom } from 'renderer/state/settings.store'; 12 | 13 | export default function ModelsLoader({ children }: { children: ReactNode }) { 14 | const [settingsState] = useAtom(settingsAtom); 15 | const [, setLorasState] = useAtom(lorasAtom); 16 | const [, setCheckpointsState] = useAtom(checkpointsAtom); 17 | 18 | useEffect(() => { 19 | const load = async () => { 20 | await loadModels(settingsState.scanModelsOnStart === '1', 'checkpoint'); 21 | await loadModels(settingsState.scanModelsOnStart === '1', 'lora'); 22 | await loadModelsTags(); 23 | window.ipcHandler.watchImagesFolder(); 24 | }; 25 | load(); 26 | // eslint-disable-next-line 27 | }, []); 28 | 29 | useEffect(() => { 30 | const cb = ( 31 | event: IpcRendererEvent, 32 | m: string, 33 | p: number, 34 | t: ModelType, 35 | ) => { 36 | if (t === 'lora') { 37 | setLorasState((draft) => { 38 | draft.importProgress = { 39 | progress: p, 40 | message: m, 41 | }; 42 | }); 43 | } 44 | if (t === 'checkpoint') { 45 | setCheckpointsState((draft) => { 46 | draft.importProgress = { 47 | progress: p, 48 | message: m, 49 | }; 50 | }); 51 | } 52 | }; 53 | 54 | const remove = window.ipcOn.modelsProgress(cb); 55 | 56 | return () => remove(); 57 | }, [setCheckpointsState, setLorasState]); 58 | 59 | return children; 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/hocs/notification.tsx: -------------------------------------------------------------------------------- 1 | import { IpcRendererEvent } from 'electron'; 2 | import { ImageRow } from 'main/ipc/image'; 3 | import { ReactNode, useEffect } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { ToastContainer, toast } from 'react-toastify'; 6 | 7 | export default function Notificator({ children }: { children: ReactNode }) { 8 | const navigate = useNavigate(); 9 | 10 | useEffect(() => { 11 | const cb = (event: IpcRendererEvent, imageData: ImageRow) => { 12 | toast(`File detected ${imageData.model} - ${imageData.sourcePath}`, { 13 | onClick: () => navigate(`/image-detail/${imageData.hash}`), 14 | closeOnClick: true, 15 | autoClose: 5000, 16 | }); 17 | }; 18 | const remove = window.ipcOn.detectedAddImage(cb); 19 | 20 | return () => remove(); 21 | // eslint-disable-next-line 22 | }, []); 23 | 24 | useEffect(() => { 25 | const cb = (event: IpcRendererEvent, msg: string, model: string) => { 26 | toast(`${msg} ${model}`, { 27 | closeOnClick: true, 28 | autoClose: 5000, 29 | pauseOnHover: true, 30 | }); 31 | }; 32 | 33 | const remove = window.ipcOn.duplicatesDetected(cb); 34 | 35 | return () => remove(); 36 | }, []); 37 | 38 | return ( 39 | <> 40 | 46 | {children} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/hocs/settings-loader.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useState } from 'react'; 2 | import { loadSettings, settingsAtom } from 'renderer/state/settings.store'; 3 | import { useAtom } from 'jotai'; 4 | 5 | export default function SettingsLoader({ children }: { children: ReactNode }) { 6 | const [loaded, setLoaded] = useState(false); 7 | const [settingsState] = useAtom(settingsAtom); 8 | 9 | useEffect(() => { 10 | // load settings 11 | const loadConf = async () => { 12 | if (!loaded) { 13 | await loadSettings(); 14 | window.document.documentElement.setAttribute( 15 | 'data-theme', 16 | settingsState.theme || 'default', 17 | ); 18 | setLoaded(true); 19 | } 20 | }; 21 | 22 | loadConf(); 23 | }, [settingsState, loaded]); 24 | 25 | if (!loaded) return null; 26 | 27 | return children; 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/hooks/tabs.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | 4 | export const tabs = { 5 | images: { 6 | id: 'images', 7 | label: 'Images', 8 | path: '', 9 | }, 10 | checkpoints: { 11 | id: 'checkpoints', 12 | label: 'Checkpoints', 13 | path: 'checkpoints', 14 | }, 15 | loras: { 16 | id: 'loras', 17 | label: 'Loras', 18 | path: 'loras', 19 | }, 20 | /* 21 | arHelper: { 22 | id: 'arHelper', 23 | label: 'AspectRatio Helper', 24 | path: 'ar-helper', 25 | }, 26 | */ 27 | imageMetadata: { 28 | id: 'imageMetadata', 29 | label: 'Png Info', 30 | path: 'image-metadata', 31 | }, 32 | /* 33 | test: { 34 | id: 'test', 35 | label: 'test', 36 | path: 'test', 37 | }, 38 | */ 39 | }; 40 | export type Tabs = keyof typeof tabs | null; 41 | 42 | export default function useTab() { 43 | const [tab, setTab] = useState(null); 44 | 45 | const location = useLocation(); 46 | const pathName = location.pathname; 47 | 48 | useEffect(() => { 49 | const currentTab = Object.values(tabs).find( 50 | (t) => `/${t.path}` === pathName, 51 | ); 52 | if (currentTab) { 53 | setTab(currentTab.id as Tabs); 54 | } else { 55 | setTab(null); 56 | } 57 | }, [pathName]); 58 | 59 | return tab; 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/hooks/useOnUnmount.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList, useEffect, useRef } from 'react'; 2 | 3 | const useOnUnmount = ( 4 | callback: () => void, 5 | dependencies: DependencyList | undefined, 6 | ) => { 7 | const isUnmounting = useRef(false); 8 | 9 | useEffect( 10 | () => () => { 11 | isUnmounting.current = true; 12 | }, 13 | [], 14 | ); 15 | 16 | useEffect( 17 | () => () => { 18 | if (isUnmounting.current) { 19 | callback(); 20 | } 21 | }, 22 | // eslint-disable-next-line react-hooks/exhaustive-deps 23 | [dependencies], 24 | ); 25 | }; 26 | 27 | export default useOnUnmount; 28 | -------------------------------------------------------------------------------- /src/renderer/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | SD Manager 10 | 11 | 12 |
    13 | 14 | 15 | -------------------------------------------------------------------------------- /src/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from './App'; 3 | 4 | const container = document.getElementById('root') as HTMLElement; 5 | const root = createRoot(container); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /src/renderer/layouts/Main.layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import Navbar from 'renderer/components/Navbar'; 3 | 4 | export default function MainLayout() { 5 | return ( 6 |
    7 | 8 |
    12 | 13 |
    14 |
    15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/pages/aspectRatioHelper.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /* 4 | const aspectRatios = [ 5 | { 6 | label: 'wide (16:9)', 7 | ratioWidth: 16, 8 | ratioHeight: 9, 9 | }, 10 | { 11 | label: 'square', 12 | ratioWidth: 1, 13 | ratioHeight: 1, 14 | }, 15 | { 16 | label: 'photography 4:3', 17 | ratioWidth: 4, 18 | ratioHeight: 3, 19 | }, 20 | { 21 | label: 'photography 3:2', 22 | ratioWidth: 3, 23 | ratioHeight: 2, 24 | }, 25 | ]; 26 | */ 27 | 28 | export default function AspectRatioHelper() { 29 | const [ratioWidth, setRatioWidth] = useState(16); 30 | const [ratioHeight, setRatioHeight] = useState(9); 31 | const [width, setWidth] = useState(16); 32 | const [height, setHeight] = useState(9); 33 | 34 | // width > ratioWidth 35 | // height > ratioHeight 36 | useEffect(() => { 37 | const h = (width * ratioHeight) / ratioWidth; 38 | if (h >= ratioHeight) { 39 | setHeight(Math.round(h)); 40 | } 41 | }, [width, ratioHeight, ratioWidth]); 42 | 43 | useEffect(() => { 44 | const w = (height * ratioWidth) / ratioHeight; 45 | if (w >= ratioWidth) { 46 | setWidth(Math.round(w)); 47 | } 48 | }, [height, ratioHeight, ratioWidth]); 49 | 50 | return ( 51 |
    52 |
    53 |

    Aspect Ratio Helper

    54 |
    55 |
    56 |
    57 |
    58 |
    59 | 62 | setRatioWidth(parseInt(e.target.value, 10))} 67 | value={ratioWidth} 68 | /> 69 |
    70 |
    71 | 74 | setWidth(parseInt(e.target.value, 10))} 79 | value={width} 80 | /> 81 |
    82 |
    83 | setWidth(parseInt(e.target.value, 10))} 89 | value={width} 90 | /> 91 |
    92 |
    93 |
    94 |
    95 | 98 | setRatioHeight(parseInt(e.target.value, 10))} 103 | value={ratioHeight} 104 | /> 105 |
    106 |
    107 | 110 | setHeight(parseInt(e.target.value, 10))} 115 | value={height} 116 | /> 117 |
    118 |
    119 | setHeight(parseInt(e.target.value, 10))} 125 | value={height} 126 | /> 127 |
    128 |
    129 |
    130 |
    131 |
    132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /src/renderer/pages/checkpoints.tsx: -------------------------------------------------------------------------------- 1 | import Models from './models'; 2 | 3 | export default function Checkpoints() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/pages/imageMetadata.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, DragEvent, useEffect, useState } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import CodeMirror from '@uiw/react-codemirror'; 4 | import { json as jsonLang } from '@codemirror/lang-json'; 5 | import Image from 'renderer/components/Image'; 6 | 7 | export default function ImageMetadata() { 8 | const IMAGE_TYPES = ['image/png', 'image/jpeg']; 9 | 10 | const [metadata, setMetadata] = useState({}); 11 | const [path, setPath] = useState(''); 12 | 13 | const [height, setHeight] = useState(window.innerHeight - 300); 14 | 15 | useEffect(() => { 16 | const onResize = () => { 17 | setHeight(window.innerHeight - 300); 18 | }; 19 | 20 | window.addEventListener('resize', onResize); 21 | 22 | return () => window.removeEventListener('resize', onResize); 23 | }, []); 24 | 25 | const readMetadata = async (newPath: string) => { 26 | setPath(newPath); 27 | const result = await window.ipcHandler.readImageMetadata(newPath); 28 | setMetadata(result); 29 | }; 30 | 31 | const onFilesChange = (e: ChangeEvent) => { 32 | if (e.target.files && e.target.files.length > 0) { 33 | if (IMAGE_TYPES.includes(e.target.files.item(0)?.type || '')) { 34 | readMetadata(e.target.files[0].path); 35 | } 36 | } 37 | }; 38 | 39 | const onFilesDrop = (e: DragEvent) => { 40 | e.preventDefault(); 41 | if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { 42 | if (IMAGE_TYPES.includes(e.dataTransfer.files.item(0)?.type || '')) { 43 | readMetadata(e.dataTransfer.files[0].path); 44 | } 45 | } 46 | }; 47 | 48 | return ( 49 | 56 |
    57 |

    Image metadata

    58 |
    59 |
    60 |
    onFilesDrop(e)} 63 | onDragOver={(e) => e.preventDefault()} 64 | > 65 | 101 |
    102 |
    106 | 112 |
    113 |
    114 |
    115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /src/renderer/pages/loras.tsx: -------------------------------------------------------------------------------- 1 | import Models from './models'; 2 | 3 | export default function Loras() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/pages/modelImageDetail.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai'; 2 | import { ImageMetaData, ModelInfoImage } from 'main/interfaces'; 3 | import { Model } from 'main/ipc/model'; 4 | import { useContext, useEffect, useState } from 'react'; 5 | import { Link, useNavigate, useParams } from 'react-router-dom'; 6 | import ImageMegadata from 'renderer/components/ImageMetadata'; 7 | import ImageZoom from 'renderer/components/ImageZoom'; 8 | import { OsContext } from 'renderer/hocs/detect-os'; 9 | import { checkpointsAtom, lorasAtom } from 'renderer/state/models.store'; 10 | import { convertPath } from 'renderer/utils'; 11 | 12 | export default function ModelImageDetail() { 13 | const os = useContext(OsContext); 14 | 15 | const navigate = useNavigate(); 16 | const navigatorParams = useParams(); 17 | const { index, hash } = navigatorParams; 18 | 19 | const [model, setModel] = useState(); 20 | const [metadata, setMetadata] = useState(null); 21 | const [imagePath, setImagePath] = useState(''); 22 | const [loras] = useAtom(lorasAtom); 23 | const [checkpoints] = useAtom(checkpointsAtom); 24 | 25 | useEffect(() => { 26 | const load = async () => { 27 | let tempModel: Model | undefined; 28 | if (hash) { 29 | tempModel = loras.models[hash]; 30 | if (!tempModel) { 31 | tempModel = checkpoints.models[hash]; 32 | } 33 | if (!tempModel) { 34 | navigate(-1); 35 | } 36 | 37 | setModel(tempModel); 38 | } 39 | 40 | if (tempModel) { 41 | const lastBackslashIndex = tempModel.path.lastIndexOf('\\'); 42 | 43 | if (lastBackslashIndex !== -1) { 44 | try { 45 | const path = tempModel.path.substring(0, lastBackslashIndex); 46 | const fullPath = convertPath( 47 | `${path}\\${tempModel.fileName}\\${tempModel.fileName}_${index}`, 48 | os, 49 | ); 50 | const metaString = await window.ipcHandler.readFile( 51 | `${fullPath}.json`, 52 | 'utf-8', 53 | ); 54 | const meta: ModelInfoImage = JSON.parse(metaString); 55 | setImagePath(`${fullPath}.png`); 56 | setMetadata({ 57 | model: `${model?.name} ${model?.baseModel}`, 58 | cfg: `${meta.meta.cfgScale}`, 59 | generatedBy: '', 60 | positivePrompt: meta.meta.prompt, 61 | negativePrompt: meta.meta.negativePrompt, 62 | sampler: meta.meta.sampler, 63 | scheduler: meta.meta.sampler, 64 | seed: `${meta.meta.seed}`, 65 | steps: `${meta.meta.steps}`, 66 | }); 67 | } catch (error) { 68 | console.log(error); 69 | } 70 | } 71 | } 72 | }; 73 | 74 | load(); 75 | }, [model, index, checkpoints.models, loras.models, hash, navigate, os]); 76 | 77 | return ( 78 |
    79 |
    80 |
    81 | 82 | {model?.name} {model?.baseModel} 83 | 84 |
    85 |
    86 | 92 |
    93 |
    94 | 95 |
    96 |
    97 |
    98 |
    99 |
    100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/renderer/pages/testing.tsx: -------------------------------------------------------------------------------- 1 | export default function TestPage() { 2 | return ( 3 |
    4 |

    Test

    5 |
    6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/preload.d.ts: -------------------------------------------------------------------------------- 1 | import { IpcRendererEvent } from 'electron'; 2 | import { Channels } from '../main/preload'; 3 | 4 | declare global { 5 | interface Window { 6 | ipcHandler: Record Promise>; 7 | ipcOn: { 8 | modelsProgress: ( 9 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 10 | ) => () => void; 11 | imagesProgress: ( 12 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 13 | ) => () => void; 14 | detectedAddImage: ( 15 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 16 | ) => () => void; 17 | duplicatesDetected: ( 18 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 19 | ) => () => void; 20 | checkingModelUpdate: ( 21 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 22 | ) => () => void; 23 | modelToUpdate: ( 24 | cb: (event: IpcRendererEvent, ...args: any[]) => void, 25 | ) => () => void; 26 | startDrag: (fileName: string) => void; 27 | }; 28 | 29 | api: { 30 | clearCache: () => void; 31 | }; 32 | } 33 | } 34 | 35 | export {}; 36 | -------------------------------------------------------------------------------- /src/renderer/state/images.store.ts: -------------------------------------------------------------------------------- 1 | import { atomWithImmer } from 'jotai-immer'; 2 | import { atom } from 'jotai'; 3 | import { ImageRow } from 'main/ipc/image'; 4 | import { Tag } from 'main/ipc/tag'; 5 | import { WatchFolder } from 'main/ipc/watchFolders'; 6 | import { createId } from '@paralleldrive/cuid2'; 7 | import { getTextColorFromBackgroundColor } from 'renderer/utils'; 8 | import { SelectValue } from 'react-tailwindcss-select/dist/components/type'; 9 | import { ImageWithTags, ImportProgress } from './interfaces'; 10 | import { settingsAtom } from './settings.store'; 11 | import { store } from './index'; 12 | 13 | export type ImagesState = { 14 | images: ImageRow[]; 15 | loading: boolean; 16 | importProgress: ImportProgress; 17 | toDelete: Record; 18 | lightbox: { 19 | currentHash: string; 20 | isOpen: boolean; 21 | }; 22 | }; 23 | 24 | export const imagesAtom = atomWithImmer({ 25 | images: [], 26 | loading: false, 27 | importProgress: { 28 | message: '', 29 | progress: 0, 30 | }, 31 | toDelete: {}, 32 | lightbox: { 33 | currentHash: '', 34 | isOpen: false, 35 | }, 36 | }); 37 | imagesAtom.debugLabel = 'imagesAtom'; 38 | 39 | export const imagesTagsAtom = atomWithImmer>({}); 40 | imagesTagsAtom.debugLabel = 'imagesTagsAtom'; 41 | 42 | export const watchFoldersAtom = atomWithImmer([]); 43 | watchFoldersAtom.debugLabel = 'watchFoldersAtom'; 44 | 45 | export const imagesWithTags = atom((get) => { 46 | const images = get(imagesAtom).images; 47 | const tags = get(imagesTagsAtom); 48 | 49 | return images.reduce((acc: ImageWithTags[], img) => { 50 | const imageTags = Object.keys(img.tags).map((t) => tags[t]); 51 | acc.push({ ...img, tags: imageTags }); 52 | return acc; 53 | }, []); 54 | }); 55 | imagesWithTags.debugLabel = 'imagesWithTags'; 56 | 57 | export const loadImages = async () => { 58 | store.set(imagesAtom, (draft) => { 59 | draft.loading = true; 60 | }); 61 | 62 | const images = await window.ipcHandler.getImages(); 63 | store.set(imagesAtom, (draft) => { 64 | draft.images = images; 65 | draft.loading = false; 66 | }); 67 | }; 68 | 69 | export const scanImages = async (paths: string[]) => { 70 | store.set(imagesAtom, (draft) => { 71 | draft.loading = true; 72 | }); 73 | 74 | await window.ipcHandler.scanImages(paths); 75 | 76 | store.set(imagesAtom, (draft) => { 77 | draft.loading = false; 78 | }); 79 | }; 80 | 81 | export const deleteImages = async () => { 82 | const imagesToDelete = store.get(imagesAtom).toDelete; 83 | 84 | await window.ipcHandler.removeImages(imagesToDelete); 85 | await store.set(imagesAtom, (draft) => { 86 | draft.toDelete = {}; 87 | }); 88 | window.api.clearCache(); 89 | await loadImages(); 90 | }; 91 | 92 | export const updateImage = async ( 93 | imageHash: string, 94 | field: keyof ImageRow, 95 | value: any, 96 | ) => { 97 | await window.ipcHandler.updateImage(imageHash, field, value); 98 | 99 | store.set(imagesAtom, (draft) => { 100 | const index = draft.images.findIndex((img) => img.hash === imageHash); 101 | if (index !== -1) { 102 | draft.images[index] = { 103 | ...draft.images[index], 104 | [field]: value, 105 | }; 106 | } 107 | }); 108 | }; 109 | 110 | export const loadWatchFolders = async () => { 111 | const watchFolders = await window.ipcHandler.watchFolder('read'); 112 | 113 | store.set(watchFoldersAtom, () => { 114 | return watchFolders; 115 | }); 116 | }; 117 | 118 | export const loadImagesTags = async () => { 119 | const tags = await window.ipcHandler.tag('read'); 120 | 121 | store.set(imagesTagsAtom, () => { 122 | return tags; 123 | }); 124 | }; 125 | 126 | export const createImageTag = async (label: string, bgColor: string) => { 127 | const payload = { 128 | id: createId(), 129 | label, 130 | color: getTextColorFromBackgroundColor(bgColor), 131 | bgColor, 132 | }; 133 | await window.ipcHandler.tag('add', payload); 134 | 135 | store.set(imagesTagsAtom, (draft) => { 136 | draft[payload.id] = payload; 137 | }); 138 | 139 | store.set(settingsAtom, (draft) => { 140 | if ( 141 | draft.activeTags === undefined || 142 | draft.activeTags === '' || 143 | draft.activeTags === null 144 | ) { 145 | draft.activeTags = payload.id; 146 | } else { 147 | const activeTagsArr = draft.activeTags.split(','); 148 | activeTagsArr.push(payload.id); 149 | draft.activeTags = activeTagsArr.join(','); 150 | } 151 | }); 152 | }; 153 | 154 | export const deleteImageTag = async (tagId: string) => { 155 | await window.ipcHandler.tag('delete', { id: tagId }); 156 | 157 | store.set(imagesTagsAtom, (draft) => { 158 | const shallowTags = { ...draft }; 159 | delete shallowTags[tagId]; 160 | return shallowTags; 161 | }); 162 | 163 | store.set(settingsAtom, (draft) => { 164 | const activeTagsSetting = 165 | draft.activeTags !== '' ? draft.activeTags?.split(',') || [] : []; 166 | const activeTagsSettingIndex = activeTagsSetting.findIndex( 167 | (t) => t === tagId, 168 | ); 169 | if (activeTagsSettingIndex !== -1) { 170 | activeTagsSetting.splice(activeTagsSettingIndex, 1); 171 | draft.activeTags = activeTagsSetting.join(','); 172 | } 173 | }); 174 | }; 175 | 176 | export const editImageTag = async ( 177 | tagId: string, 178 | label: string, 179 | bgColor: string, 180 | ) => { 181 | const payload = { 182 | id: tagId, 183 | label, 184 | bgColor, 185 | color: getTextColorFromBackgroundColor(bgColor), 186 | }; 187 | await window.ipcHandler.tag('edit', payload); 188 | 189 | store.set(imagesTagsAtom, (draft) => { 190 | draft[tagId] = payload; 191 | }); 192 | }; 193 | 194 | export const tagImage = async (tagId: string, imageHash: string) => { 195 | await window.ipcHandler.tagImage(tagId, imageHash); 196 | 197 | store.set(imagesAtom, (draft) => { 198 | const image = draft.images.find((img) => img.hash === imageHash); 199 | if (image) { 200 | if (image.tags[tagId]) { 201 | const tags = { ...image.tags }; 202 | delete tags[tagId]; 203 | image.tags = tags; 204 | } else { 205 | image.tags[tagId] = tagId; 206 | } 207 | } 208 | }); 209 | }; 210 | 211 | export const removeAllImagesTags = async (imageHash: string) => { 212 | await window.ipcHandler.removeAllImageTags(imageHash); 213 | 214 | store.set(imagesAtom, (draft) => { 215 | const image = draft.images.find((img) => img.hash === imageHash); 216 | if (image) { 217 | image.tags = {}; 218 | } 219 | }); 220 | }; 221 | 222 | export const regenerateThumbnails = async () => { 223 | store.set(imagesAtom, (draft) => { 224 | draft.loading = true; 225 | }); 226 | 227 | await window.ipcHandler.regenerateThumbnails(); 228 | 229 | store.set(imagesAtom, (draft) => { 230 | draft.loading = false; 231 | }); 232 | }; 233 | 234 | export const setActiveTags = async (selectedTags?: SelectValue) => { 235 | const payload = Array.isArray(selectedTags) 236 | ? selectedTags.map((t) => t.value).join(',') 237 | : ''; 238 | await window.ipcHandler.settings('save', 'activeTags', payload); 239 | 240 | store.set(settingsAtom, (draft) => { 241 | draft.activeTags = payload; 242 | }); 243 | }; 244 | 245 | export const setAutoImportTags = async (selectedTags?: SelectValue) => { 246 | const payload = Array.isArray(selectedTags) 247 | ? selectedTags.map((t) => t.value).join(',') 248 | : ''; 249 | await window.ipcHandler.settings('save', 'autoImportTags', payload); 250 | 251 | store.set(settingsAtom, (draft) => { 252 | draft.autoImportTags = payload; 253 | }); 254 | }; 255 | -------------------------------------------------------------------------------- /src/renderer/state/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'jotai'; 2 | 3 | export const store = createStore(); 4 | -------------------------------------------------------------------------------- /src/renderer/state/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { ImageRow } from 'main/ipc/image'; 2 | import { Model } from 'main/ipc/model'; 3 | import { Tag } from 'main/ipc/tag'; 4 | 5 | export type ImageWithTags = Omit & { tags: Tag[] }; 6 | 7 | export type ModelWithTags = Omit & { tags: Tag[] }; 8 | 9 | export type ImportProgress = { 10 | progress: number; 11 | message: string; 12 | }; 13 | -------------------------------------------------------------------------------- /src/renderer/state/models.store.ts: -------------------------------------------------------------------------------- 1 | import { createId } from '@paralleldrive/cuid2'; 2 | import { atom } from 'jotai'; 3 | import { atomWithImmer } from 'jotai-immer'; 4 | import { getTextColorFromBackgroundColor } from 'renderer/utils'; 5 | import { Model, ModelType } from 'main/ipc/model'; 6 | import { SelectValue } from 'react-tailwindcss-select/dist/components/type'; 7 | import { Tag } from 'main/ipc/tag'; 8 | import { ImportProgress, ModelWithTags } from './interfaces'; 9 | import { settingsAtom } from './settings.store'; 10 | import { store } from './index'; 11 | 12 | export type UpdateState = { 13 | needUpdate: boolean; 14 | loading: boolean; 15 | }; 16 | 17 | export type ModelState = { 18 | models: Record; 19 | update: Record; 20 | loading: boolean; 21 | checkingUpdates: boolean; 22 | importProgress: ImportProgress; 23 | }; 24 | 25 | export const checkpointsAtom = atomWithImmer({ 26 | models: {}, 27 | update: {}, 28 | loading: false, 29 | checkingUpdates: false, 30 | importProgress: { 31 | message: '', 32 | progress: 0, 33 | }, 34 | }); 35 | checkpointsAtom.debugLabel = 'checkpointsAtom'; 36 | 37 | export const lorasAtom = atomWithImmer({ 38 | models: {}, 39 | update: {}, 40 | loading: false, 41 | checkingUpdates: false, 42 | importProgress: { 43 | message: '', 44 | progress: 0, 45 | }, 46 | }); 47 | lorasAtom.debugLabel = 'lorasAtom'; 48 | 49 | export const modelsAtom: Record = { 50 | lora: lorasAtom, 51 | checkpoint: checkpointsAtom, 52 | }; 53 | 54 | export const modelTagsAtom = atomWithImmer>({}); 55 | modelTagsAtom.debugLabel = 'modelTagsAtom'; 56 | 57 | export const lorasWithTags = atom((get) => { 58 | const loras = get(lorasAtom); 59 | const mtags = get(modelTagsAtom); 60 | 61 | return Object.values(loras.models).reduce((acc: ModelWithTags[], lora) => { 62 | const modelTags = Object.keys(lora.tags).map((t) => mtags[t]); 63 | acc.push({ ...lora, tags: modelTags }); 64 | return acc; 65 | }, []); 66 | }); 67 | lorasWithTags.debugLabel = 'lorasWithTags'; 68 | 69 | export const checkpointWithTags = atom((get) => { 70 | const checkpoints = get(checkpointsAtom); 71 | const mtags = get(modelTagsAtom); 72 | 73 | return Object.values(checkpoints.models).reduce( 74 | (acc: ModelWithTags[], checkpoint) => { 75 | const modelTags = Object.keys(checkpoint.tags).map((t) => mtags[t]); 76 | acc.push({ ...checkpoint, tags: modelTags }); 77 | return acc; 78 | }, 79 | [], 80 | ); 81 | }); 82 | checkpointWithTags.debugLabel = 'checkpointWithTags'; 83 | 84 | export const modelsWithTags: Record = { 85 | lora: lorasWithTags, 86 | checkpoint: checkpointWithTags, 87 | }; 88 | 89 | export const loadModels = async (shouldImport: boolean, type: ModelType) => { 90 | const settings = store.get(settingsAtom); 91 | 92 | store.set(modelsAtom[type], (draft) => { 93 | draft.loading = true; 94 | }); 95 | 96 | let modelsPath: string | null = ''; 97 | if (type === 'lora') { 98 | modelsPath = settings.lorasPath; 99 | } 100 | if (type === 'checkpoint') { 101 | modelsPath = settings.checkpointsPath; 102 | } 103 | 104 | if (shouldImport && modelsPath) { 105 | await window.ipcHandler.readdirModels(type, modelsPath); 106 | } 107 | 108 | const models: Record = 109 | await window.ipcHandler.readModels(type); 110 | 111 | store.set(modelsAtom[type], (draft) => { 112 | draft.models = models; 113 | draft.loading = false; 114 | }); 115 | }; 116 | 117 | export const updateModel = async ( 118 | modelHash: string, 119 | type: ModelType, 120 | field: keyof Model, 121 | value: any, 122 | ) => { 123 | await window.ipcHandler.updateModel(modelHash, field, value); 124 | 125 | store.set(modelsAtom[type], (draft) => { 126 | draft.models[modelHash] = { 127 | ...draft.models[modelHash], 128 | [field]: value, 129 | }; 130 | }); 131 | }; 132 | 133 | export const loadModelsTags = async () => { 134 | const tags = await window.ipcHandler.mtag('read'); 135 | 136 | store.set(modelTagsAtom, () => { 137 | return tags; 138 | }); 139 | }; 140 | 141 | export const createModelTag = async (label: string, bgColor: string) => { 142 | const payload = { 143 | id: createId(), 144 | label, 145 | color: getTextColorFromBackgroundColor(bgColor), 146 | bgColor, 147 | }; 148 | await window.ipcHandler.mtag('add', payload); 149 | 150 | store.set(modelTagsAtom, (draft) => { 151 | draft[payload.id] = payload; 152 | }); 153 | 154 | store.set(settingsAtom, (draft) => { 155 | if ( 156 | draft.activeMTags === undefined || 157 | draft.activeMTags === '' || 158 | draft.activeMTags === null 159 | ) { 160 | draft.activeMTags = payload.id; 161 | } else { 162 | const activeTagsArr = draft.activeMTags.split(','); 163 | activeTagsArr.push(payload.id); 164 | draft.activeMTags = activeTagsArr.join(','); 165 | } 166 | }); 167 | }; 168 | 169 | export const deleteModelTag = async (tagId: string) => { 170 | await window.ipcHandler.mtag('delete', { id: tagId }); 171 | 172 | store.set(modelTagsAtom, (draft) => { 173 | const shallowTags = { ...draft }; 174 | delete shallowTags[tagId]; 175 | return shallowTags; 176 | }); 177 | 178 | store.set(settingsAtom, (draft) => { 179 | const activeTagsSetting = 180 | draft.activeMTags !== '' 181 | ? draft.activeMTags?.split(',') || [draft.activeMTags] 182 | : []; 183 | const activeTagsSettingIndex = activeTagsSetting.findIndex( 184 | (t) => t === tagId, 185 | ); 186 | if (activeTagsSettingIndex !== -1) { 187 | activeTagsSetting.splice(activeTagsSettingIndex, 1); 188 | draft.activeMTags = activeTagsSetting.join(','); 189 | } 190 | }); 191 | }; 192 | 193 | export const editModelTag = async ( 194 | tagId: string, 195 | label: string, 196 | bgColor: string, 197 | ) => { 198 | const payload = { 199 | id: tagId, 200 | label, 201 | bgColor, 202 | color: getTextColorFromBackgroundColor(bgColor), 203 | }; 204 | await window.ipcHandler.mtag('edit', payload); 205 | 206 | store.set(modelTagsAtom, (draft) => { 207 | draft[tagId] = payload; 208 | }); 209 | }; 210 | 211 | export const tagModel = async ( 212 | tagId: string, 213 | modelHash: string, 214 | type: ModelType, 215 | ) => { 216 | await window.ipcHandler.tagModel(tagId, modelHash); 217 | 218 | store.set(modelsAtom[type], (draft) => { 219 | const model = draft.models[modelHash]; 220 | if (model) { 221 | if (model.tags[tagId]) { 222 | const mtags = { ...model.tags }; 223 | delete mtags[tagId]; 224 | model.tags = mtags; 225 | } else { 226 | model.tags[tagId] = tagId; 227 | } 228 | } 229 | }); 230 | }; 231 | 232 | export const removeAllModelsTags = async ( 233 | modelHash: string, 234 | type: ModelType, 235 | ) => { 236 | await window.ipcHandler.removeAllModelsTags(modelHash); 237 | 238 | store.set(modelsAtom[type], (draft) => { 239 | draft.models[modelHash].tags = {}; 240 | }); 241 | }; 242 | 243 | export const setActiveMTags = async (selectedTags?: SelectValue) => { 244 | const payload = Array.isArray(selectedTags) 245 | ? selectedTags.map((t) => t.value).join(',') 246 | : ''; 247 | await window.ipcHandler.settings('save', 'activeMTags', payload); 248 | 249 | store.set(settingsAtom, (draft) => { 250 | draft.activeMTags = payload; 251 | }); 252 | }; 253 | 254 | export const setModelsCheckingUpdate = ( 255 | type: ModelType, 256 | modelId: number, 257 | loading: boolean, 258 | ) => { 259 | store.set(modelsAtom[type], (draft) => { 260 | if (!draft.update[modelId]) { 261 | draft.update[modelId] = { 262 | loading, 263 | needUpdate: false, 264 | }; 265 | } else { 266 | draft.update[modelId].loading = loading; 267 | } 268 | }); 269 | }; 270 | 271 | export const setModelsToUpdate = (type: ModelType, modelId: number) => { 272 | store.set(modelsAtom[type], (draft) => { 273 | draft.update[modelId] = { 274 | loading: false, 275 | needUpdate: true, 276 | }; 277 | }); 278 | }; 279 | -------------------------------------------------------------------------------- /src/renderer/state/navbar.store.ts: -------------------------------------------------------------------------------- 1 | import { atomWithImmer } from 'jotai-immer'; 2 | 3 | export type NavbarState = { 4 | disabled: boolean; 5 | searchInput: string; 6 | filterCheckpoint: string; 7 | }; 8 | 9 | export const navbarAtom = atomWithImmer({ 10 | disabled: false, 11 | searchInput: '', 12 | filterCheckpoint: '', 13 | }); 14 | navbarAtom.debugLabel = 'navbarAtom'; 15 | -------------------------------------------------------------------------------- /src/renderer/state/settings.store.ts: -------------------------------------------------------------------------------- 1 | import { atomWithImmer } from 'jotai-immer'; 2 | import { store } from './index'; 3 | 4 | export type SettingsState = { 5 | scanModelsOnStart: string | null; 6 | scanImagesOnStart: string | null; 7 | checkpointsPath: string | null; 8 | lorasPath: string | null; 9 | theme: string | null; 10 | activeTags: string | null; 11 | activeMTags: string | null; 12 | autoImportImages: string | null; 13 | autoImportTags: string | null; 14 | autoTagImportImages: string | null; 15 | }; 16 | 17 | export const settingsAtom = atomWithImmer({ 18 | scanModelsOnStart: '0', 19 | scanImagesOnStart: '0', 20 | checkpointsPath: null, 21 | lorasPath: null, 22 | theme: 'default', 23 | activeTags: null, 24 | activeMTags: null, 25 | autoImportImages: '0', 26 | autoImportTags: null, 27 | autoTagImportImages: null, 28 | }); 29 | settingsAtom.debugLabel = 'settingsAtom'; 30 | 31 | export const loadSettings = async () => { 32 | const settings = await window.ipcHandler.settings('readAll'); 33 | store.set(settingsAtom, () => { 34 | return settings; 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/renderer/utils.ts: -------------------------------------------------------------------------------- 1 | export function debounce( 2 | func: (...args: T) => void, 3 | d: number, 4 | ) { 5 | let timerId: ReturnType; 6 | 7 | return (...args: T) => { 8 | clearTimeout(timerId); 9 | 10 | timerId = setTimeout(() => { 11 | func(...args); 12 | }, d); 13 | }; 14 | } 15 | 16 | export function throttle(func: Function, d: number): Function { 17 | let lastExecTime = 0; 18 | let timeoutId: ReturnType | null; 19 | 20 | return function (...args: any[]) { 21 | const now = Date.now(); 22 | 23 | if (now - lastExecTime < d) { 24 | // Cancel the previous setTimeout 25 | if (timeoutId) { 26 | clearTimeout(timeoutId); 27 | } 28 | 29 | // Set a new setTimeout 30 | timeoutId = setTimeout(() => { 31 | lastExecTime = now; 32 | func(...args); 33 | }, d); 34 | } else { 35 | lastExecTime = now; 36 | func(...args); 37 | } 38 | }; 39 | } 40 | 41 | const saveMd = async (path: string, value: string) => { 42 | await window.ipcHandler.saveMD(path, value); 43 | }; 44 | 45 | export const saveMdDebounced = debounce(saveMd, 1000); 46 | 47 | export function generateRandomId(length: number) { 48 | const charset = 49 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 50 | let randomId = ''; 51 | const crypto = window.crypto; 52 | 53 | if (crypto && crypto.getRandomValues) { 54 | const values = new Uint32Array(length); 55 | crypto.getRandomValues(values); 56 | 57 | for (let i = 0; i < length; i++) { 58 | randomId += charset[values[i] % charset.length]; 59 | } 60 | } else { 61 | // Fallback for older browsers that don't support crypto.getRandomValues 62 | for (let i = 0; i < length; i++) { 63 | randomId += charset[Math.floor(Math.random() * charset.length)]; 64 | } 65 | } 66 | 67 | return randomId; 68 | } 69 | 70 | export function getTextColorFromBackgroundColor(bgColorHex: string): string { 71 | // Convert the hex color to RGB values. 72 | const r = parseInt(bgColorHex.slice(1, 3), 16); 73 | const g = parseInt(bgColorHex.slice(3, 5), 16); 74 | const b = parseInt(bgColorHex.slice(5, 7), 16); 75 | 76 | // Calculate the relative luminance. 77 | const luminance = 0.299 * r + 0.587 * g + 0.114 * b; 78 | 79 | // Determine the text color based on luminance. 80 | const textColorHex = luminance > 128 ? '#000000' : '#FFFFFF'; 81 | 82 | return textColorHex; 83 | } 84 | 85 | type Box = { 86 | x: number; 87 | y: number; 88 | width: number; 89 | height: number; 90 | }; 91 | 92 | export function areBoxesIntersecting(box1: Box, box2: Box): boolean { 93 | return ( 94 | box1.x < box2.x + box2.width && 95 | box1.x + box1.width > box2.x && 96 | box1.y < box2.y + box2.height && 97 | box1.y + box1.height > box2.y 98 | ); 99 | } 100 | 101 | export function delay(ms: number): Promise { 102 | return new Promise((resolve) => { 103 | setTimeout(resolve, ms); 104 | }); 105 | } 106 | 107 | export function convertPath(inputPath: string, os: string): string { 108 | if (os === 'win32') return inputPath; 109 | 110 | if (os === 'linux') return inputPath.replace(/\\/g, '/'); 111 | 112 | return inputPath; 113 | } 114 | 115 | export function getFileDir(inputPath: string, os: string): string { 116 | let delimiter = '\\'; 117 | if (os === 'win32') { 118 | delimiter = '\\'; 119 | } 120 | 121 | if (os === 'linux') { 122 | delimiter = '/'; 123 | } 124 | 125 | return inputPath.split(delimiter).slice(0, -1).join(delimiter); 126 | } 127 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const themes = require('./themes'); 2 | /* eslint global-require: off, import/no-extraneous-dependencies: off */ 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | content: [ 7 | './src/**/*.{js,ts,jsx,tsx,mdx}', 8 | './node_modules/react-tailwindcss-select/dist/index.esm.js', 9 | ], 10 | plugins: [require('@tailwindcss/typography'), require('daisyui')], 11 | daisyui: { 12 | themes: [ 13 | ...themes, 14 | { 15 | default: { 16 | primary: '#9ece6a', 17 | secondary: '#f7768e', 18 | accent: '#ff9e64', 19 | neutral: '#414868', 20 | 'base-100': '#1a1b26', 21 | info: '#b4f9f8', 22 | success: '#2f9e44', 23 | warning: '#e03131', 24 | error: '#fa5252', 25 | }, 26 | }, 27 | ], 28 | base: true, 29 | darkTheme: 'coffee', 30 | styled: true, 31 | utils: true, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /themes.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'light', 3 | 'dark', 4 | 'coffee', 5 | 'cupcake', 6 | 'bumblebee', 7 | 'emerald', 8 | 'corporate', 9 | 'synthwave', 10 | 'retro', 11 | 'cyberpunk', 12 | 'valentine', 13 | 'halloween', 14 | 'garden', 15 | 'forest', 16 | 'aqua', 17 | 'lofi', 18 | 'pastel', 19 | 'fantasy', 20 | 'wireframe', 21 | 'black', 22 | 'luxury', 23 | 'dracula', 24 | 'cmyk', 25 | 'autumn', 26 | 'business', 27 | 'acid', 28 | 'lemonade', 29 | 'night', 30 | 'winter', 31 | ]; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "ESNext", 5 | "module": "CommonJS", 6 | "lib": ["dom", "ESNext"], 7 | "jsx": "react-jsx", 8 | "strict": true, 9 | "sourceMap": true, 10 | "baseUrl": "./src", 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "resolveJsonModule": true, 15 | "allowJs": true, 16 | "outDir": ".erb/dll" 17 | }, 18 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"] 19 | } 20 | --------------------------------------------------------------------------------