diff --git a/apps/server-web/.editorconfig b/apps/server-web/.editorconfig new file mode 100644 index 000000000..4a7ea3036 --- /dev/null +++ b/apps/server-web/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/apps/server-web/.erb/configs/.eslintrc b/apps/server-web/.erb/configs/.eslintrc new file mode 100644 index 000000000..89d242ba7 --- /dev/null +++ b/apps/server-web/.erb/configs/.eslintrc @@ -0,0 +1,7 @@ +{ + "rules": { + "no-console": "off", + "global-require": "off", + "import/no-dynamic-require": "off" + } +} diff --git a/apps/server-web/.erb/configs/webpack.config.base.ts b/apps/server-web/.erb/configs/webpack.config.base.ts new file mode 100644 index 000000000..0ef00445a --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.base.ts @@ -0,0 +1,59 @@ +/** + * Base webpack config used across other specific configs + */ + +import webpack from 'webpack'; +import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin'; +import webpackPaths from './webpack.paths'; +import { dependencies as externals } from '../../release/app/package.json'; + +const configuration: webpack.Configuration = { + externals: [...Object.keys(externals || {})], + + stats: 'errors-only', + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: 'ts-loader', + options: { + // Remove this line to enable type checking in webpack builds + transpileOnly: true, + compilerOptions: { + module: 'esnext', + }, + }, + }, + }, + ], + }, + + output: { + path: webpackPaths.srcPath, + // https://github.com/webpack/webpack/issues/1114 + library: { + type: 'commonjs2', + }, + }, + + /** + * Determine the array of extensions that should be used to resolve modules. + */ + resolve: { + extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], + modules: [webpackPaths.srcPath, 'node_modules'], + // There is no need to add aliases here, the paths in tsconfig get mirrored + plugins: [new TsconfigPathsPlugins()], + }, + + plugins: [ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + }), + ], +}; + +export default configuration; diff --git a/apps/server-web/.erb/configs/webpack.config.eslint.ts b/apps/server-web/.erb/configs/webpack.config.eslint.ts new file mode 100644 index 000000000..35a631b7c --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.eslint.ts @@ -0,0 +1,3 @@ +/* eslint import/no-unresolved: off, import/no-self-import: off */ + +module.exports = require('./webpack.config.renderer.dev').default; diff --git a/apps/server-web/.erb/configs/webpack.config.main.prod.ts b/apps/server-web/.erb/configs/webpack.config.main.prod.ts new file mode 100644 index 000000000..472748219 --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.main.prod.ts @@ -0,0 +1,83 @@ +/** + * Webpack config for production electron main process + */ + +import path from 'path'; +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import TerserPlugin from 'terser-webpack-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; +import checkNodeEnv from '../scripts/check-node-env'; +import deleteSourceMaps from '../scripts/delete-source-maps'; + +checkNodeEnv('production'); +deleteSourceMaps(); + +const configuration: webpack.Configuration = { + devtool: 'source-map', + + mode: 'production', + + target: 'electron-main', + + entry: { + main: path.join(webpackPaths.srcMainPath, 'main.ts'), + preload: path.join(webpackPaths.srcMainPath, 'preload.ts'), + }, + + output: { + path: webpackPaths.distMainPath, + filename: '[name].js', + library: { + type: 'umd', + }, + }, + + optimization: { + minimizer: [ + new TerserPlugin({ + parallel: true, + }), + ], + }, + + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + analyzerPort: 8888, + }), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + DEBUG_PROD: false, + START_MINIMIZED: false, + }), + + new webpack.DefinePlugin({ + 'process.type': '"browser"', + }), + ], + + /** + * Disables webpack processing of __dirname and __filename. + * If you run the bundle in node.js it falls back to these values of node.js. + * https://github.com/webpack/webpack/issues/2010 + */ + node: { + __dirname: false, + __filename: false, + }, +}; + +export default merge(baseConfig, configuration); diff --git a/apps/server-web/.erb/configs/webpack.config.preload.dev.ts b/apps/server-web/.erb/configs/webpack.config.preload.dev.ts new file mode 100644 index 000000000..d6679e63e --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.preload.dev.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import webpack from 'webpack'; +import { merge } from 'webpack-merge'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; +import checkNodeEnv from '../scripts/check-node-env'; + +// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's +// at the dev webpack config is not accidentally run in a production environment +if (process.env.NODE_ENV === 'production') { + checkNodeEnv('development'); +} + +const configuration: webpack.Configuration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: 'electron-preload', + + entry: path.join(webpackPaths.srcMainPath, 'preload.ts'), + + output: { + path: webpackPaths.dllPath, + filename: 'preload.js', + library: { + type: 'umd', + }, + }, + + plugins: [ + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + }), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + * + * By default, use 'development' as NODE_ENV. This can be overriden with + * 'staging', for example, by changing the ENV variables in the npm scripts + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + }), + ], + + /** + * Disables webpack processing of __dirname and __filename. + * If you run the bundle in node.js it falls back to these values of node.js. + * https://github.com/webpack/webpack/issues/2010 + */ + node: { + __dirname: false, + __filename: false, + }, + + watch: true, +}; + +export default merge(baseConfig, configuration); diff --git a/apps/server-web/.erb/configs/webpack.config.renderer.dev.dll.ts b/apps/server-web/.erb/configs/webpack.config.renderer.dev.dll.ts new file mode 100644 index 000000000..614b90f04 --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.renderer.dev.dll.ts @@ -0,0 +1,77 @@ +/** + * Builds the DLL for development electron renderer process + */ + +import webpack from 'webpack'; +import path from 'path'; +import { merge } from 'webpack-merge'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; +import { dependencies } from '../../package.json'; +import checkNodeEnv from '../scripts/check-node-env'; + +checkNodeEnv('development'); + +const dist = webpackPaths.dllPath; + +const configuration: webpack.Configuration = { + context: webpackPaths.rootPath, + + devtool: 'eval', + + mode: 'development', + + target: 'electron-renderer', + + externals: ['fsevents', 'crypto-browserify'], + + /** + * Use `module` from `webpack.config.renderer.dev.js` + */ + module: require('./webpack.config.renderer.dev').default.module, + + entry: { + renderer: Object.keys(dependencies || {}), + }, + + output: { + path: dist, + filename: '[name].dev.dll.js', + library: { + name: 'renderer', + type: 'var', + }, + }, + + plugins: [ + new webpack.DllPlugin({ + path: path.join(dist, '[name].json'), + name: '[name]', + }), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + options: { + context: webpackPaths.srcPath, + output: { + path: webpackPaths.dllPath, + }, + }, + }), + ], +}; + +export default merge(baseConfig, configuration); diff --git a/apps/server-web/.erb/configs/webpack.config.renderer.dev.ts b/apps/server-web/.erb/configs/webpack.config.renderer.dev.ts new file mode 100644 index 000000000..a6ca87e90 --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.renderer.dev.ts @@ -0,0 +1,213 @@ +import 'webpack-dev-server'; +import path from 'path'; +import fs from 'fs'; +import webpack from 'webpack'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; +import chalk from 'chalk'; +import { merge } from 'webpack-merge'; +import { execSync, spawn } from 'child_process'; +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; +import checkNodeEnv from '../scripts/check-node-env'; + +// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's +// at the dev webpack config is not accidentally run in a production environment +if (process.env.NODE_ENV === 'production') { + checkNodeEnv('development'); +} + +const port = process.env.PORT || 1212; +const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json'); +const skipDLLs = + module.parent?.filename.includes('webpack.config.renderer.dev.dll') || + module.parent?.filename.includes('webpack.config.eslint'); + +/** + * Warn if the DLL is not built + */ +if ( + !skipDLLs && + !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest)) +) { + console.log( + chalk.black.bgYellow.bold( + 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', + ), + ); + execSync('npm run postinstall'); +} + +const configuration: webpack.Configuration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: ['web', 'electron-renderer'], + + entry: [ + `webpack-dev-server/client?http://localhost:${port}/dist`, + 'webpack/hot/only-dev-server', + path.join(webpackPaths.srcRendererPath, 'index.tsx'), + ], + + output: { + path: webpackPaths.distRendererPath, + publicPath: '/', + filename: 'renderer.dev.js', + library: { + type: 'umd', + }, + }, + + module: { + rules: [ + { + test: /\.s?(c|a)ss$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: true, + importLoaders: 1, + }, + }, + 'sass-loader', + ], + include: /\.module\.s?(c|a)ss$/, + }, + { + test: /\.s?css$/, + use: ['style-loader', 'css-loader', 'sass-loader'], + exclude: /\.module\.s?(c|a)ss$/, + }, + // Fonts + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: 'asset/resource', + }, + // Images + { + test: /\.(png|jpg|jpeg|gif)$/i, + type: 'asset/resource', + }, + // SVG + { + test: /\.svg$/, + use: [ + { + loader: '@svgr/webpack', + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [{ removeViewBox: false }], + }, + titleProp: true, + ref: true, + }, + }, + 'file-loader', + ], + }, + ], + }, + plugins: [ + ...(skipDLLs + ? [] + : [ + new webpack.DllReferencePlugin({ + context: webpackPaths.dllPath, + manifest: require(manifest), + sourceType: 'var', + }), + ]), + + new webpack.NoEmitOnErrorsPlugin(), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + * + * By default, use 'development' as NODE_ENV. This can be overriden with + * 'staging', for example, by changing the ENV variables in the npm scripts + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), + + new webpack.LoaderOptionsPlugin({ + debug: true, + }), + + new ReactRefreshWebpackPlugin(), + + new HtmlWebpackPlugin({ + filename: path.join('index.html'), + template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), + minify: { + collapseWhitespace: true, + removeAttributeQuotes: true, + removeComments: true, + }, + isBrowser: false, + env: process.env.NODE_ENV, + isDevelopment: process.env.NODE_ENV !== 'production', + nodeModules: webpackPaths.appNodeModulesPath, + }), + ], + + node: { + __dirname: false, + __filename: false, + }, + + devServer: { + port, + compress: true, + hot: true, + headers: { 'Access-Control-Allow-Origin': '*' }, + static: { + publicPath: '/', + }, + historyApiFallback: { + verbose: true, + }, + setupMiddlewares(middlewares) { + console.log('Starting preload.js builder...'); + const preloadProcess = spawn('npm', ['run', 'start:preload'], { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => process.exit(code!)) + .on('error', (spawnError) => console.error(spawnError)); + + console.log('Starting Main Process...'); + let args = ['run', 'start:main']; + if (process.env.MAIN_ARGS) { + args = args.concat( + ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(), + ); + } + spawn('npm', args, { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => { + preloadProcess.kill(); + process.exit(code!); + }) + .on('error', (spawnError) => console.error(spawnError)); + return middlewares; + }, + }, +}; + +export default merge(baseConfig, configuration); diff --git a/apps/server-web/.erb/configs/webpack.config.renderer.prod.ts b/apps/server-web/.erb/configs/webpack.config.renderer.prod.ts new file mode 100644 index 000000000..3cebf30d4 --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.config.renderer.prod.ts @@ -0,0 +1,141 @@ +/** + * Build config for electron renderer process + */ + +import path from 'path'; +import webpack from 'webpack'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; +import MiniCssExtractPlugin from 'mini-css-extract-plugin'; +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'; +import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'; +import { merge } from 'webpack-merge'; +import TerserPlugin from 'terser-webpack-plugin'; +import baseConfig from './webpack.config.base'; +import webpackPaths from './webpack.paths'; +import checkNodeEnv from '../scripts/check-node-env'; +import deleteSourceMaps from '../scripts/delete-source-maps'; + +checkNodeEnv('production'); +deleteSourceMaps(); + +const configuration: webpack.Configuration = { + devtool: 'source-map', + + mode: 'production', + + target: ['web', 'electron-renderer'], + + entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')], + + output: { + path: webpackPaths.distRendererPath, + publicPath: './', + filename: 'renderer.js', + library: { + type: 'umd', + }, + }, + + module: { + rules: [ + { + test: /\.s?(a|c)ss$/, + use: [ + MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: true, + importLoaders: 1, + }, + }, + 'sass-loader', + ], + include: /\.module\.s?(c|a)ss$/, + }, + { + test: /\.s?(a|c)ss$/, + use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], + exclude: /\.module\.s?(c|a)ss$/, + }, + // Fonts + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: 'asset/resource', + }, + // Images + { + test: /\.(png|jpg|jpeg|gif)$/i, + type: 'asset/resource', + }, + // SVG + { + test: /\.svg$/, + use: [ + { + loader: '@svgr/webpack', + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [{ removeViewBox: false }], + }, + titleProp: true, + ref: true, + }, + }, + 'file-loader', + ], + }, + ], + }, + + optimization: { + minimize: true, + minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], + }, + + plugins: [ + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'production', + DEBUG_PROD: false, + }), + + new MiniCssExtractPlugin({ + filename: 'style.css', + }), + + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + analyzerPort: 8889, + }), + + new HtmlWebpackPlugin({ + filename: 'index.html', + template: path.join(webpackPaths.srcRendererPath, 'index.ejs'), + minify: { + collapseWhitespace: true, + removeAttributeQuotes: true, + removeComments: true, + }, + isBrowser: false, + isDevelopment: false, + }), + + new webpack.DefinePlugin({ + 'process.type': '"renderer"', + }), + ], +}; + +export default merge(baseConfig, configuration); diff --git a/apps/server-web/.erb/configs/webpack.paths.ts b/apps/server-web/.erb/configs/webpack.paths.ts new file mode 100644 index 000000000..e5ba57343 --- /dev/null +++ b/apps/server-web/.erb/configs/webpack.paths.ts @@ -0,0 +1,38 @@ +const path = require('path'); + +const rootPath = path.join(__dirname, '../..'); + +const dllPath = path.join(__dirname, '../dll'); + +const srcPath = path.join(rootPath, 'src'); +const srcMainPath = path.join(srcPath, 'main'); +const srcRendererPath = path.join(srcPath, 'renderer'); + +const releasePath = path.join(rootPath, 'release'); +const appPath = path.join(releasePath, 'app'); +const appPackagePath = path.join(appPath, 'package.json'); +const appNodeModulesPath = path.join(appPath, 'node_modules'); +const srcNodeModulesPath = path.join(srcPath, 'node_modules'); + +const distPath = path.join(appPath, 'dist'); +const distMainPath = path.join(distPath, 'main'); +const distRendererPath = path.join(distPath, 'renderer'); + +const buildPath = path.join(releasePath, 'build'); + +export default { + rootPath, + dllPath, + srcPath, + srcMainPath, + srcRendererPath, + releasePath, + appPath, + appPackagePath, + appNodeModulesPath, + srcNodeModulesPath, + distPath, + distMainPath, + distRendererPath, + buildPath, +}; diff --git a/apps/server-web/.erb/img/erb-banner.svg b/apps/server-web/.erb/img/erb-banner.svg new file mode 100644 index 000000000..f7ce67079 --- /dev/null +++ b/apps/server-web/.erb/img/erb-banner.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/server-web/.erb/img/erb-logo.png b/apps/server-web/.erb/img/erb-logo.png new file mode 100644 index 000000000..97a5661ae Binary files /dev/null and b/apps/server-web/.erb/img/erb-logo.png differ diff --git a/apps/server-web/.erb/img/palette-sponsor-banner.svg b/apps/server-web/.erb/img/palette-sponsor-banner.svg new file mode 100644 index 000000000..c1abcfd27 --- /dev/null +++ b/apps/server-web/.erb/img/palette-sponsor-banner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/server-web/.erb/mocks/fileMock.js b/apps/server-web/.erb/mocks/fileMock.js new file mode 100644 index 000000000..602eb23ee --- /dev/null +++ b/apps/server-web/.erb/mocks/fileMock.js @@ -0,0 +1 @@ +export default 'test-file-stub'; diff --git a/apps/server-web/.erb/scripts/.eslintrc b/apps/server-web/.erb/scripts/.eslintrc new file mode 100644 index 000000000..35dc618d6 --- /dev/null +++ b/apps/server-web/.erb/scripts/.eslintrc @@ -0,0 +1,8 @@ +{ + "rules": { + "no-console": "off", + "global-require": "off", + "import/no-dynamic-require": "off", + "import/no-extraneous-dependencies": "off" + } +} diff --git a/apps/server-web/.erb/scripts/check-build-exists.ts b/apps/server-web/.erb/scripts/check-build-exists.ts new file mode 100644 index 000000000..649929572 --- /dev/null +++ b/apps/server-web/.erb/scripts/check-build-exists.ts @@ -0,0 +1,24 @@ +// Check if the renderer and main bundles are built +import path from 'path'; +import chalk from 'chalk'; +import fs from 'fs'; +import webpackPaths from '../configs/webpack.paths'; + +const mainPath = path.join(webpackPaths.distMainPath, 'main.js'); +const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js'); + +if (!fs.existsSync(mainPath)) { + throw new Error( + chalk.whiteBright.bgRed.bold( + 'The main process is not built yet. Build it by running "npm run build:main"', + ), + ); +} + +if (!fs.existsSync(rendererPath)) { + throw new Error( + chalk.whiteBright.bgRed.bold( + 'The renderer process is not built yet. Build it by running "npm run build:renderer"', + ), + ); +} diff --git a/apps/server-web/.erb/scripts/check-native-dep.js b/apps/server-web/.erb/scripts/check-native-dep.js new file mode 100644 index 000000000..628698162 --- /dev/null +++ b/apps/server-web/.erb/scripts/check-native-dep.js @@ -0,0 +1,54 @@ +import fs from 'fs'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import { dependencies } from '../../package.json'; + +if (dependencies) { + const dependenciesKeys = Object.keys(dependencies); + const nativeDeps = fs + .readdirSync('node_modules') + .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`)); + if (nativeDeps.length === 0) { + process.exit(0); + } + try { + // Find the reason for why the dependency is installed. If it is installed + // because of a devDependency then that is okay. Warn when it is installed + // because of a dependency + const { dependencies: dependenciesObject } = JSON.parse( + execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(), + ); + const rootDependencies = Object.keys(dependenciesObject); + const filteredRootDependencies = rootDependencies.filter((rootDependency) => + dependenciesKeys.includes(rootDependency), + ); + if (filteredRootDependencies.length > 0) { + const plural = filteredRootDependencies.length > 1; + console.log(` + ${chalk.whiteBright.bgYellow.bold( + 'Webpack does not work with native dependencies.', + )} +${chalk.bold(filteredRootDependencies.join(', '))} ${ + plural ? 'are native dependencies' : 'is a native dependency' + } and should be installed inside of the "./release/app" folder. + First, uninstall the packages from "./package.json": +${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')} + ${chalk.bold( + 'Then, instead of installing the package to the root "./package.json":', + )} +${chalk.whiteBright.bgRed.bold('npm install your-package')} + ${chalk.bold('Install the package to "./release/app/package.json"')} +${chalk.whiteBright.bgGreen.bold( + 'cd ./release/app && npm install your-package', +)} + Read more about native dependencies at: +${chalk.bold( + 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure', +)} + `); + process.exit(1); + } + } catch (e) { + console.log('Native dependencies could not be checked'); + } +} diff --git a/apps/server-web/.erb/scripts/check-node-env.js b/apps/server-web/.erb/scripts/check-node-env.js new file mode 100644 index 000000000..6bf674baa --- /dev/null +++ b/apps/server-web/.erb/scripts/check-node-env.js @@ -0,0 +1,16 @@ +import chalk from 'chalk'; + +export default function checkNodeEnv(expectedEnv) { + if (!expectedEnv) { + throw new Error('"expectedEnv" not set'); + } + + if (process.env.NODE_ENV !== expectedEnv) { + console.log( + chalk.whiteBright.bgRed.bold( + `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`, + ), + ); + process.exit(2); + } +} diff --git a/apps/server-web/.erb/scripts/check-port-in-use.js b/apps/server-web/.erb/scripts/check-port-in-use.js new file mode 100644 index 000000000..398cbc179 --- /dev/null +++ b/apps/server-web/.erb/scripts/check-port-in-use.js @@ -0,0 +1,16 @@ +import chalk from 'chalk'; +import detectPort from 'detect-port'; + +const port = process.env.PORT || '1212'; + +detectPort(port, (_err, availablePort) => { + if (port !== String(availablePort)) { + throw new Error( + chalk.whiteBright.bgRed.bold( + `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`, + ), + ); + } else { + process.exit(0); + } +}); diff --git a/apps/server-web/.erb/scripts/clean.js b/apps/server-web/.erb/scripts/clean.js new file mode 100644 index 000000000..2c7b3aeab --- /dev/null +++ b/apps/server-web/.erb/scripts/clean.js @@ -0,0 +1,13 @@ +import { rimrafSync } from 'rimraf'; +import fs from 'fs'; +import webpackPaths from '../configs/webpack.paths'; + +const foldersToRemove = [ + webpackPaths.distPath, + webpackPaths.buildPath, + webpackPaths.dllPath, +]; + +foldersToRemove.forEach((folder) => { + if (fs.existsSync(folder)) rimrafSync(folder); +}); diff --git a/apps/server-web/.erb/scripts/delete-source-maps.js b/apps/server-web/.erb/scripts/delete-source-maps.js new file mode 100644 index 000000000..d14519cd0 --- /dev/null +++ b/apps/server-web/.erb/scripts/delete-source-maps.js @@ -0,0 +1,15 @@ +import fs from 'fs'; +import path from 'path'; +import { rimrafSync } from 'rimraf'; +import webpackPaths from '../configs/webpack.paths'; + +export default function deleteSourceMaps() { + if (fs.existsSync(webpackPaths.distMainPath)) + rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), { + glob: true, + }); + if (fs.existsSync(webpackPaths.distRendererPath)) + rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), { + glob: true, + }); +} diff --git a/apps/server-web/.erb/scripts/electron-rebuild.js b/apps/server-web/.erb/scripts/electron-rebuild.js new file mode 100644 index 000000000..0bea32793 --- /dev/null +++ b/apps/server-web/.erb/scripts/electron-rebuild.js @@ -0,0 +1,20 @@ +import { execSync } from 'child_process'; +import fs from 'fs'; +import { dependencies } from '../../release/app/package.json'; +import webpackPaths from '../configs/webpack.paths'; + +if ( + Object.keys(dependencies || {}).length > 0 && + fs.existsSync(webpackPaths.appNodeModulesPath) +) { + const electronRebuildCmd = + '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .'; + const cmd = + process.platform === 'win32' + ? electronRebuildCmd.replace(/\//g, '\\') + : electronRebuildCmd; + execSync(cmd, { + cwd: webpackPaths.appPath, + stdio: 'inherit', + }); +} diff --git a/apps/server-web/.erb/scripts/link-modules.ts b/apps/server-web/.erb/scripts/link-modules.ts new file mode 100644 index 000000000..6cc31e666 --- /dev/null +++ b/apps/server-web/.erb/scripts/link-modules.ts @@ -0,0 +1,9 @@ +import fs from 'fs'; +import webpackPaths from '../configs/webpack.paths'; + +const { srcNodeModulesPath } = webpackPaths; +const { appNodeModulesPath } = webpackPaths; + +if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) { + fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction'); +} diff --git a/apps/server-web/.erb/scripts/notarize.js b/apps/server-web/.erb/scripts/notarize.js new file mode 100644 index 000000000..097ff35b7 --- /dev/null +++ b/apps/server-web/.erb/scripts/notarize.js @@ -0,0 +1,32 @@ +const { notarize } = require('@electron/notarize'); +const { build } = require('../../package.json'); + +exports.default = async function notarizeMacos(context) { + const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== 'darwin') { + return; + } + + if (process.env.CI !== 'true') { + console.warn('Skipping notarizing step. Packaging is not running in CI'); + return; + } + + if ( + !('APPLE_ID' in process.env && 'APPLE_APP_SPECIFIC_PASSWORD' in process.env) + ) { + console.warn( + 'Skipping notarizing step. APPLE_ID and APPLE_APP_SPECIFIC_PASSWORD env variables must be set', + ); + return; + } + + const appName = context.packager.appInfo.productFilename; + + await notarize({ + appBundleId: build.appId, + appPath: `${appOutDir}/${appName}.app`, + appleId: process.env.APPLE_ID, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, + }); +}; diff --git a/apps/server-web/.eslintignore b/apps/server-web/.eslintignore new file mode 100644 index 000000000..7cad53588 --- /dev/null +++ b/apps/server-web/.eslintignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Coverage directory used by tools like istanbul +coverage +.eslintcache + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store + +release/app/dist +release/build +.erb/dll + +.idea +npm-debug.log.* +*.css.d.ts +*.sass.d.ts +*.scss.d.ts + +# eslint ignores hidden directories by default: +# https://github.com/eslint/eslint/issues/8429 +!.erb diff --git a/apps/server-web/.eslintrc.js b/apps/server-web/.eslintrc.js new file mode 100644 index 000000000..85a1aa65f --- /dev/null +++ b/apps/server-web/.eslintrc.js @@ -0,0 +1,34 @@ +module.exports = { + extends: 'erb', + plugins: ['@typescript-eslint'], + rules: { + // A temporary hack related to IDE not resolving correct package.json + 'import/no-extraneous-dependencies': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-filename-extension': 'off', + 'import/extensions': 'off', + 'import/no-unresolved': 'off', + 'import/no-import-module-exports': 'off', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'error', + }, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + settings: { + 'import/resolver': { + // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below + node: {}, + webpack: { + config: require.resolve('./.erb/configs/webpack.config.eslint.ts'), + }, + typescript: {}, + }, + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + }, +}; diff --git a/apps/server-web/.gitattributes b/apps/server-web/.gitattributes new file mode 100644 index 000000000..20570f2f3 --- /dev/null +++ b/apps/server-web/.gitattributes @@ -0,0 +1,12 @@ +* text eol=lf +*.exe binary +*.png binary +*.jpg binary +*.jpeg binary +*.ico binary +*.icns binary +*.eot binary +*.otf binary +*.ttf binary +*.woff binary +*.woff2 binary diff --git a/apps/server-web/.gitignore b/apps/server-web/.gitignore new file mode 100644 index 000000000..93b6f44fd --- /dev/null +++ b/apps/server-web/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Coverage directory used by tools like istanbul +coverage +.eslintcache + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store + +release/app/dist +release/build +.erb/dll + +.idea +npm-debug.log.* +*.css.d.ts +*.sass.d.ts +*.scss.d.ts \ No newline at end of file diff --git a/apps/server-web/.vscode/launch.json b/apps/server-web/.vscode/launch.json new file mode 100644 index 000000000..43b848402 --- /dev/null +++ b/apps/server-web/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Nextron: Main", + "type": "node", + "request": "attach", + "protocol": "inspector", + "port": 9292, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } + }, + { + "name": "Nextron: Renderer", + "type": "chrome", + "request": "attach", + "port": 5858, + "timeout": 10000, + "urlFilter": "http://localhost:*", + "webRoot": "${workspaceFolder}/app", + "sourceMapPathOverrides": { + "webpack:///./src/*": "${webRoot}/*" + } + } + ], + "compounds": [ + { + "name": "Nextron: All", + "preLaunchTask": "dev", + "configurations": ["Nextron: Main", "Nextron: Renderer"] + } + ] +} diff --git a/apps/server-web/.vscode/tasks.json b/apps/server-web/.vscode/tasks.json new file mode 100644 index 000000000..57290398b --- /dev/null +++ b/apps/server-web/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "isBackground": true, + "problemMatcher": { + "owner": "custom", + "pattern": { + "regexp": "" + }, + "background": { + "beginsPattern": "started server", + "endsPattern": "Debugger listening on" + } + }, + "label": "dev" + } + ] +} diff --git a/apps/server-web/README.md b/apps/server-web/README.md index 9d0d9808e..782109f1f 100644 --- a/apps/server-web/README.md +++ b/apps/server-web/README.md @@ -1,3 +1,3 @@ # Ever Teams Web Server -Electron-based Desktop App that serve Ever Teams NextJs frontend. +Electron-based Desktop App that serve Ever Teams NextJs frontend. \ No newline at end of file diff --git a/apps/server-web/assets/assets.d.ts b/apps/server-web/assets/assets.d.ts new file mode 100644 index 000000000..251085e97 --- /dev/null +++ b/apps/server-web/assets/assets.d.ts @@ -0,0 +1,35 @@ +type Styles = Record; + +declare module '*.svg' { + import React = require('react'); + + export const ReactComponent: React.FC>; + + const content: string; + export default content; +} + +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.jpg' { + const content: string; + export default content; +} + +declare module '*.scss' { + const content: Styles; + export default content; +} + +declare module '*.sass' { + const content: Styles; + export default content; +} + +declare module '*.css' { + const content: Styles; + export default content; +} diff --git a/apps/server-web/assets/entitlements.mac.plist b/apps/server-web/assets/entitlements.mac.plist new file mode 100644 index 000000000..dad3e20e6 --- /dev/null +++ b/apps/server-web/assets/entitlements.mac.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + + diff --git a/apps/server-web/assets/icon.icns b/apps/server-web/assets/icon.icns new file mode 100644 index 000000000..c2213ce89 Binary files /dev/null and b/apps/server-web/assets/icon.icns differ diff --git a/apps/server-web/assets/icon.ico b/apps/server-web/assets/icon.ico new file mode 100644 index 000000000..98948ea68 Binary files /dev/null and b/apps/server-web/assets/icon.ico differ diff --git a/apps/server-web/assets/icon.png b/apps/server-web/assets/icon.png new file mode 100755 index 000000000..755a6e51d Binary files /dev/null and b/apps/server-web/assets/icon.png differ diff --git a/apps/server-web/assets/icon.svg b/apps/server-web/assets/icon.svg new file mode 100644 index 000000000..b064abf9f --- /dev/null +++ b/apps/server-web/assets/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/server-web/assets/icons/1024x1024.png b/apps/server-web/assets/icons/1024x1024.png new file mode 100755 index 000000000..5940b65a3 Binary files /dev/null and b/apps/server-web/assets/icons/1024x1024.png differ diff --git a/apps/server-web/assets/icons/128x128.png b/apps/server-web/assets/icons/128x128.png new file mode 100755 index 000000000..14e578d24 Binary files /dev/null and b/apps/server-web/assets/icons/128x128.png differ diff --git a/apps/server-web/assets/icons/16x16.png b/apps/server-web/assets/icons/16x16.png new file mode 100755 index 000000000..260a46cb0 Binary files /dev/null and b/apps/server-web/assets/icons/16x16.png differ diff --git a/apps/server-web/assets/icons/24x24.png b/apps/server-web/assets/icons/24x24.png new file mode 100755 index 000000000..56172416c Binary files /dev/null and b/apps/server-web/assets/icons/24x24.png differ diff --git a/apps/server-web/assets/icons/256x256.png b/apps/server-web/assets/icons/256x256.png new file mode 100755 index 000000000..755a6e51d Binary files /dev/null and b/apps/server-web/assets/icons/256x256.png differ diff --git a/apps/server-web/assets/icons/32x32.png b/apps/server-web/assets/icons/32x32.png new file mode 100755 index 000000000..63423dfec Binary files /dev/null and b/apps/server-web/assets/icons/32x32.png differ diff --git a/apps/server-web/assets/icons/48x48.png b/apps/server-web/assets/icons/48x48.png new file mode 100755 index 000000000..74d87a0cf Binary files /dev/null and b/apps/server-web/assets/icons/48x48.png differ diff --git a/apps/server-web/assets/icons/512x512.png b/apps/server-web/assets/icons/512x512.png new file mode 100755 index 000000000..313cd499d Binary files /dev/null and b/apps/server-web/assets/icons/512x512.png differ diff --git a/apps/server-web/assets/icons/64x64.png b/apps/server-web/assets/icons/64x64.png new file mode 100755 index 000000000..6de0ec0e0 Binary files /dev/null and b/apps/server-web/assets/icons/64x64.png differ diff --git a/apps/server-web/assets/icons/96x96.png b/apps/server-web/assets/icons/96x96.png new file mode 100755 index 000000000..8255ab58c Binary files /dev/null and b/apps/server-web/assets/icons/96x96.png differ diff --git a/apps/server-web/electronmon.js b/apps/server-web/electronmon.js new file mode 100644 index 000000000..90b2221d1 --- /dev/null +++ b/apps/server-web/electronmon.js @@ -0,0 +1,5 @@ +require('ts-node').register({ + transpileOnly: true +}); + +require('./src/main/main.ts'); \ No newline at end of file diff --git a/apps/server-web/package.json b/apps/server-web/package.json new file mode 100644 index 000000000..fa95261b3 --- /dev/null +++ b/apps/server-web/package.json @@ -0,0 +1,253 @@ +{ + "name": "@ever-teams/server-web", + "version": "0.1.0", + "description": "Ever Teams Web Server", + "license": "AGPL-3.0", + "homepage": "https://ever.team", + "repository": { + "type": "git", + "url": "https://github.com/ever-co/ever-teams.git" + }, + "bugs": { + "url": "https://github.com/ever-co/ever-teams/issues" + }, + "private": true, + "author": { + "name": "Ever Co. LTD", + "email": "ever@ever.co", + "url": "https://ever.co" + }, + "main": "./src/main/main.ts", + "scripts": { + "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"", + "build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts", + "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts", + "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", + "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", + "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx", + "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", + "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", + "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer", + "start:main": "cross-env NODE_ENV=development electronmon electronmon.js", + "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", + "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", + "test": "jest" + }, + "dependencies": { + "electron-debug": "^3.2.0", + "electron-log": "^4.4.8", + "electron-updater": "^6.1.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.16.0", + "electron-store": "^8.1.0" + }, + "devDependencies": { + "electron": "28.1.0", + "electron-builder": "^24.6.4", + "@electron/notarize": "^2.1.0", + "@electron/rebuild": "^3.3.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", + "@teamsupercell/typings-for-css-modules-loader": "^2.5.2", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.5.5", + "@types/react-test-renderer": "^18.0.1", + "@types/terser-webpack-plugin": "^5.0.4", + "@types/webpack-bundle-analyzer": "^4.6.0", + "@typescript-eslint/eslint-plugin": "^6.7.0", + "@typescript-eslint/parser": "^6.7.0", + "browserslist-config-erb": "^0.0.3", + "chalk": "^4.1.2", + "concurrently": "^8.2.1", + "core-js": "^3.32.2", + "cross-env": "^7.0.3", + "css-loader": "^6.8.1", + "css-minimizer-webpack-plugin": "^5.0.1", + "detect-port": "^1.5.1", + "electron-devtools-installer": "^3.2.0", + "electronmon": "^2.0.2", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-erb": "^4.1.0-0", + "eslint-import-resolver-typescript": "^3.6.0", + "eslint-import-resolver-webpack": "^0.13.7", + "eslint-plugin-compat": "^4.2.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jest": "^27.4.0", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "file-loader": "^6.2.0", + "html-webpack-plugin": "^5.5.3", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "mini-css-extract-plugin": "^2.7.6", + "prettier": "^3.0.3", + "react-refresh": "^0.14.0", + "react-test-renderer": "^18.2.0", + "rimraf": "^5.0.1", + "sass": "^1.67.0", + "sass-loader": "^13.3.2", + "style-loader": "^3.3.3", + "terser-webpack-plugin": "^5.3.9", + "ts-jest": "^29.1.1", + "ts-loader": "^9.4.4", + "ts-node": "^10.9.1", + "tsconfig-paths-webpack-plugin": "^4.1.0", + "url-loader": "^4.1.1", + "webpack": "^5.88.2", + "webpack-bundle-analyzer": "^4.9.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" + }, + "prettier": { + "singleQuote": true, + "overrides": [ + { + "files": [ + ".prettierrc", + ".eslintrc" + ], + "options": { + "parser": "json" + } + } + ] + }, + "jest": { + "moduleDirectories": [ + "node_modules", + "release/app/node_modules", + "src" + ], + "moduleFileExtensions": [ + "js", + "jsx", + "ts", + "tsx", + "json" + ], + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy" + }, + "setupFiles": [ + "./.erb/scripts/check-build-exists.ts" + ], + "testEnvironment": "jsdom", + "testEnvironmentOptions": { + "url": "http://localhost/" + }, + "testPathIgnorePatterns": [ + "release/app/dist", + ".erb/dll" + ], + "transform": { + "\\.(ts|tsx|js|jsx)$": "ts-jest" + } + }, + "engines": { + "node": ">=16.0.0", + "yarn": ">=1.13.0" + }, + "build": { + "appId": "co.ever.teamswebserver", + "artifactName": "${name}-${version}.${ext}", + "productName": "Ever Teams Web Server", + "copyright": "Copyright © 2024-Present. Ever Co. LTD", + "dmg": { + "sign": false + }, + "asar": true, + "asarUnpack": "**\\*.{node,dll}", + "files": [ + "dist", + "node_modules", + "package.json" + ], + "afterSign": ".erb/scripts/notarize.js", + "mac": { + "category": "public.app-category.developer-tools", + "target": [ + "zip", + "dmg" + ], + "asarUnpack": "**/*.node", + "artifactName": "${name}-${version}.${ext}", + "type": "distribution", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "assets/entitlements.mac.plist", + "entitlementsInherit": "assets/entitlements.mac.plist" + }, + "dmg": { + "contents": [ + { + "x": 130, + "y": 220 + }, + { + "x": 410, + "y": 220, + "type": "link", + "path": "/Applications" + } + ] + }, + "win": { + "publisherName": "Ever", + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "verifyUpdateCodeSignature": false, + "requestedExecutionLevel": "requireAdministrator" + }, + "linux": { + "target": [ + "AppImage", + "deb", + "tar.gz" + ], + "executableName": "ever-teams-web-server", + "artifactName": "${name}-${version}.${ext}", + "synopsis": "Server", + "category": "Development" + }, + "directories": { + "app": "release/app", + "buildResources": "assets", + "output": "release/build" + }, + "extraResources": [ + "./assets/**" + ], + "publish": [{ + "provider": "github", + "repo": "ever-teams-web-server", + "releaseType": "release" + }, + { + "provider": "spaces", + "name": "ever", + "region": "sfo3", + "path": "/ever-teams-web-server", + "acl": "public-read" + } + ] + }, + "electronmon": { + "patterns": [ + "!**/**", + "src/main/**" + ], + "logLevel": "quiet" + } +} diff --git a/apps/server-web/release/app/package-lock.json b/apps/server-web/release/app/package-lock.json new file mode 100644 index 000000000..351a9dab9 --- /dev/null +++ b/apps/server-web/release/app/package-lock.json @@ -0,0 +1,14 @@ +{ + "name": "electron-react-boilerplate", + "version": "4.6.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "electron-react-boilerplate", + "version": "4.6.0", + "hasInstallScript": true, + "license": "MIT" + } + } +} diff --git a/apps/server-web/release/app/package.json b/apps/server-web/release/app/package.json new file mode 100644 index 000000000..de2f9ac9c --- /dev/null +++ b/apps/server-web/release/app/package.json @@ -0,0 +1,18 @@ +{ + "name": "electron-react-boilerplate", + "version": "4.6.0", + "description": "A foundation for scalable desktop apps", + "license": "MIT", + "author": { + "name": "Electron React Boilerplate Maintainers", + "email": "electronreactboilerplate@gmail.com", + "url": "https://github.com/electron-react-boilerplate" + }, + "main": "./dist/main/main.js", + "scripts": { + "rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js", + "postinstall": "npm run rebuild && npm run link-modules", + "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts" + }, + "dependencies": {} +} diff --git a/apps/server-web/release/app/yarn.lock b/apps/server-web/release/app/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/apps/server-web/release/app/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/apps/server-web/src/__tests__/App.test.tsx b/apps/server-web/src/__tests__/App.test.tsx new file mode 100644 index 000000000..6a1de2a63 --- /dev/null +++ b/apps/server-web/src/__tests__/App.test.tsx @@ -0,0 +1,9 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import App from '../renderer/App'; + +describe('App', () => { + it('should render', () => { + expect(render()).toBeTruthy(); + }); +}); diff --git a/apps/server-web/src/main/helpers/constant.ts b/apps/server-web/src/main/helpers/constant.ts new file mode 100644 index 000000000..3090c1df5 --- /dev/null +++ b/apps/server-web/src/main/helpers/constant.ts @@ -0,0 +1,6 @@ +export const EventLists = { + webServerStarted: 'WEB_SERVER_STARTED', + webServerStopped: 'WEB_SERVER_STOPPED', + webServerStart: 'WEB_SERVER_START', + webServerStop: 'WEB_SERVER_STOP' + } diff --git a/apps/server-web/src/main/helpers/create-window.ts b/apps/server-web/src/main/helpers/create-window.ts new file mode 100644 index 000000000..b4deda5f3 --- /dev/null +++ b/apps/server-web/src/main/helpers/create-window.ts @@ -0,0 +1,86 @@ +import { + screen, + BrowserWindow, + BrowserWindowConstructorOptions, + Rectangle, +} from 'electron' +import Store from 'electron-store' + +export const createWindow = ( + windowName: string, + options: BrowserWindowConstructorOptions +): BrowserWindow => { + const key = 'window-state' + const name = `window-state-${windowName}` + const store = new Store({ name }) + const defaultSize = { + width: options.width, + height: options.height, + } + let state = {} + + const restore = () => store.get(key, defaultSize) + + const getCurrentPosition = () => { + const position = win.getPosition() + const size = win.getSize() + return { + x: position[0], + y: position[1], + width: size[0], + height: size[1], + } + } + + const windowWithinBounds = (windowState, bounds) => { + return ( + windowState.x >= bounds.x && + windowState.y >= bounds.y && + windowState.x + windowState.width <= bounds.x + bounds.width && + windowState.y + windowState.height <= bounds.y + bounds.height + ) + } + + const resetToDefaults = () => { + const bounds = screen.getPrimaryDisplay().bounds + return Object.assign({}, defaultSize, { + x: (bounds.width - defaultSize.width) / 2, + y: (bounds.height - defaultSize.height) / 2, + }) + } + + const ensureVisibleOnSomeDisplay = (windowState) => { + const visible = screen.getAllDisplays().some((display) => { + return windowWithinBounds(windowState, display.bounds) + }) + if (!visible) { + // Window is partially or fully not visible now. + // Reset it to safe defaults. + return resetToDefaults() + } + return windowState + } + + const saveState = () => { + if (!win.isMinimized() && !win.isMaximized()) { + Object.assign(state, getCurrentPosition()) + } + store.set(key, state) + } + + state = ensureVisibleOnSomeDisplay(restore()) + + const win = new BrowserWindow({ + ...state, + ...options, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + ...options.webPreferences, + }, + }) + + win.on('close', saveState) + + return win +} diff --git a/apps/server-web/src/main/helpers/desktop-server.ts b/apps/server-web/src/main/helpers/desktop-server.ts new file mode 100644 index 000000000..fc367e6a1 --- /dev/null +++ b/apps/server-web/src/main/helpers/desktop-server.ts @@ -0,0 +1,136 @@ +import { BrowserWindow } from 'electron'; +import { DesktopServerFactory } from './services/desktop-server-factory'; +import EventEmitter from 'events'; +import { Observer } from './services/utils'; +import NotificationDesktop from '../windows/desktop-notifier'; +// Define server states +export enum ServerState { + STOPPED = 'stopped', + RUNNING = 'running', + RESTARTING = 'restarting' +} + +/** + * Represents a Desktop Server. + */ +export class DesktopServer { + private state: ServerState = ServerState.STOPPED; + private stateObserver: Observer; + private eventEmitter:EventEmitter; + constructor(private readonly isOnlyApiServer = false, eventEmitter: EventEmitter) { + // super(); + + this.stateObserver = new Observer((state: ServerState) => { + this.state = state; + this.notification(state); + // this.emit('stateChange', state); + }); + this.eventEmitter = eventEmitter + } + + public async start( + path?: any, + env?: any, + mainWindow?: BrowserWindow, + signal?: AbortSignal, + ): Promise { + console.log('DesktopServer -> start'); + + try { + if (this.state !== ServerState.STOPPED) { + return; // Server already running or restarting + } + + const apiInstance = DesktopServerFactory.getApiInstance(path?.api, env, mainWindow, signal, this.eventEmitter); + await this.startInstance(apiInstance); + // Notify running state + this.stateObserver.notify(ServerState.RUNNING); + } catch (error) { + this.handleError(error); + } + } + + public async stop(): Promise { + // if (this.state === ServerState.STOPPED) { + // return; // Server already stopped + // } + + const apiInstance = DesktopServerFactory.getApiInstance(); + await this.stopInstance(apiInstance); + + // Notify stopped state + this.stateObserver.notify(ServerState.STOPPED); + } + + public async restart(): Promise { + // if (this.state === ServerState.STOPPED) { + // return; // Server is stopped no need to restarting + // } + + // if (this.state === ServerState.RESTARTING) { + // return; // Server already restarting + // } + + // Notify restarting state + this.stateObserver.notify(ServerState.RESTARTING); + + const apiInstance = DesktopServerFactory.getApiInstance(); + apiInstance?.restartObserver?.notify?.({ type: 'restart' }); + + await this.restartInstance(apiInstance); + + // Notify running state + this.stateObserver.notify(ServerState.RUNNING); + } + + public get running(): boolean { + return this.state === ServerState.RUNNING; + } + + private async stopInstance(instance: any): Promise { + if (instance) { + await instance.stop(); + } + } + + private async startInstance(instance: any): Promise { + if (instance) { + await instance.start(); + } + } + + private async restartInstance(instance: any): Promise { + if (instance) { + await instance.restart(); + } + } + + private handleError(error: Error): void { + console.error('Error occurred:', error); + } + + private get name(): string { + return process.env.DESCRIPTION || 'Server'; + } + + private notification(state: ServerState) { + let message = ''; + switch (state) { + case ServerState.STOPPED: + message = 'Server is stopped'; + break; + case ServerState.RESTARTING: + message = 'Server is restarting'; + break; + case ServerState.RUNNING: + message = 'Server is running'; + break; + default: + console.log(`ERROR: Uncaught state: ${state}`); + break; + } + + const notifier = new NotificationDesktop(); + notifier.customNotification(message, this.name); + } +} diff --git a/apps/server-web/src/main/helpers/index.ts b/apps/server-web/src/main/helpers/index.ts new file mode 100644 index 000000000..e1b9aad0a --- /dev/null +++ b/apps/server-web/src/main/helpers/index.ts @@ -0,0 +1 @@ +export * from './create-window' diff --git a/apps/server-web/src/main/helpers/interfaces/i-desktop-dialog.ts b/apps/server-web/src/main/helpers/interfaces/i-desktop-dialog.ts new file mode 100644 index 000000000..b1ce87736 --- /dev/null +++ b/apps/server-web/src/main/helpers/interfaces/i-desktop-dialog.ts @@ -0,0 +1,9 @@ +import { BrowserWindow, MessageBoxOptions } from 'electron'; + +export interface IDesktopDialog { + show(): Promise; + close(): void; + get options(): MessageBoxOptions; + set options(value: MessageBoxOptions); + get browserWindow(): BrowserWindow; +} diff --git a/apps/server-web/src/main/helpers/interfaces/i-server.ts b/apps/server-web/src/main/helpers/interfaces/i-server.ts new file mode 100644 index 000000000..ec50d5aba --- /dev/null +++ b/apps/server-web/src/main/helpers/interfaces/i-server.ts @@ -0,0 +1,6 @@ +export interface WebServer { + PORT: number; + NEXT_PUBLIC_GAUZY_API_SERVER_URL: string; + GAUZY_API_SERVER_URL: string; + [key: string]: any; +} \ No newline at end of file diff --git a/apps/server-web/src/main/helpers/interfaces/index.ts b/apps/server-web/src/main/helpers/interfaces/index.ts new file mode 100644 index 000000000..f7a793501 --- /dev/null +++ b/apps/server-web/src/main/helpers/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './i-server'; +export * from './i-desktop-dialog'; diff --git a/apps/server-web/src/main/helpers/services/desktop-server-factory.ts b/apps/server-web/src/main/helpers/services/desktop-server-factory.ts new file mode 100644 index 000000000..1a9502238 --- /dev/null +++ b/apps/server-web/src/main/helpers/services/desktop-server-factory.ts @@ -0,0 +1,13 @@ +import { BrowserWindow } from 'electron'; +import { WebService } from './web-service'; +import { EventEmitter } from 'events'; + +export class DesktopServerFactory { + private static apiInstance: WebService; + public static getApiInstance(path?: string, env?: any, win?: BrowserWindow, signal?: AbortSignal, eventEmitter?: EventEmitter): WebService { + if (!this.apiInstance && !!env) { + this.apiInstance = new WebService(path, env, win, signal, eventEmitter); + } + return this.apiInstance; + } +} diff --git a/apps/server-web/src/main/helpers/services/libs/desktop-store.ts b/apps/server-web/src/main/helpers/services/libs/desktop-store.ts new file mode 100644 index 000000000..275863cd1 --- /dev/null +++ b/apps/server-web/src/main/helpers/services/libs/desktop-store.ts @@ -0,0 +1,29 @@ +import Store from 'electron-store'; +import { WebServer } from '../../interfaces'; +const store = new Store(); +export const LocalStore = { + getStore: (source: string) => { + return store.get(source); + }, + + updateConfigSetting: (values: WebServer) => { + let config: WebServer | any = store.get('config'); + config = { ...config, ...values }; + store.set({ + config + }); + }, + + + setDefaultServerConfig: () => { + const defaultConfig: WebServer | any = store.get('config'); + if (!defaultConfig || !defaultConfig.PORT) { + const config: WebServer = { + PORT: 3002, + GAUZY_API_SERVER_URL: 'htpp://localhost:3000', + NEXT_PUBLIC_GAUZY_API_SERVER_URL: 'http://localhost:3000' + } + store.set({ config }); + } + } +}; diff --git a/apps/server-web/src/main/helpers/services/libs/server-config.ts b/apps/server-web/src/main/helpers/services/libs/server-config.ts new file mode 100644 index 000000000..2d2c1060b --- /dev/null +++ b/apps/server-web/src/main/helpers/services/libs/server-config.ts @@ -0,0 +1,11 @@ +import { LocalStore } from './desktop-store'; + +export class ServerConfig { + public get setting(): any { + return LocalStore.getStore('config'); + } + + public set setting(value:any) { + LocalStore.updateConfigSetting(value); + } +} diff --git a/apps/server-web/src/main/helpers/services/libs/server-task.ts b/apps/server-web/src/main/helpers/services/libs/server-task.ts new file mode 100644 index 000000000..dba091b67 --- /dev/null +++ b/apps/server-web/src/main/helpers/services/libs/server-task.ts @@ -0,0 +1,179 @@ +import { ChildProcessFactory, Observer } from '../utils'; +import { BrowserWindow } from 'electron'; +import { ServerConfig } from './server-config'; +import EventEmitter from 'events'; +import { EventLists } from '../../constant'; +// import { Timeout } from '../../decorators'; + +export abstract class ServerTask { + private processPath: string; + protected args: Record; + protected window: BrowserWindow; + protected successMessage: string; + private errorMessage: string; + protected config: ServerConfig; + protected loggerObserver: Observer; + private stateObserver: Observer; + public restartObserver: Observer<{ type?: string; status?: string }, void>; + protected pid: string; + protected isRunning: boolean; + protected signal: AbortSignal; + private criticalMessageError = ['[CRITICAL::ERROR]', 'EADDRINUSE']; + public eventEmmitter: EventEmitter; + + protected constructor( + processPath: string, + args: Record, + serverWindow: BrowserWindow, + successMessage: string, + errorMessage: string, + signal: AbortSignal, + eventEmmitter: EventEmitter + ) { + this.processPath = processPath; + this.args = args; + this.window = serverWindow; + this.successMessage = successMessage; + this.errorMessage = errorMessage; + this.config = new ServerConfig(); + this.pid = `${this.args.serviceName}Pid`; + this.signal = signal; + this.isRunning = false; + this.eventEmmitter = eventEmmitter; + + this.loggerObserver = new Observer((msg: string) => { + console.log('Sending log_state:', msg); + if (!this.window?.isDestroyed()) { + // this.window.webContents.send('log_state', { msg }); + } + }); + + this.stateObserver = new Observer((state: boolean) => { + this.isRunning = state; + if (!this.window?.isDestroyed()) { + console.log('Sending running_state:', state); + // this.window.webContents.send('running_state', state); + } + }); + + this.restartObserver = new Observer((options?) => { + if (!this.window?.isDestroyed()) { + console.log('Sending resp_msg:', options); + // this.window.webContents.send('resp_msg', { type: 'start_server', status: 'success', ...options }); + } + }); + } + + protected async runTask(signal: AbortSignal): Promise { + console.log('Run Server Task'); + return new Promise((resolve, reject) => { + try { + console.log('creating process with processPath:', this.processPath, 'args:', JSON.stringify(this.args)); + + const service = ChildProcessFactory.createProcess(this.processPath, this.args, signal); + + console.log('Service created', service.pid); + + service.stdout.on('data', (data: any) => { + const msg = data.toString(); + this.loggerObserver.notify(msg); + if (msg.includes(this.successMessage)) { + const name = String(this.args.serviceName); + this.stateObserver.notify(true); + this.loggerObserver.notify( + `☣︎ ${name.toUpperCase()} server listen to ${this.config[`${name}Url`]}` + ); + resolve(); + } + + if (this.criticalMessageError.some((error) => msg.includes(error))) { + this.handleError(msg); + reject(msg); + } + }); + + service.stderr.on('data', (data: any) => { + console.log('stderr:', data.toString()); + this.loggerObserver.notify(data.toString()); + }); + + service.on('disconnect', () => { + console.log('Webserver disconnected'); + if (this.eventEmmitter) { + this.eventEmmitter.emit(EventLists.webServerStopped); + } + }) + + service.on('error', (err) => { + console.log('child process error', err); + }) + + if (this.eventEmmitter) { + this.eventEmmitter.emit(EventLists.webServerStarted); + } + this.config.setting = { [this.pid]: service.pid }; + } catch (error) { + console.error('Error running task:', error); + this.handleError(error); + reject(error); + } + }); + } + + public kill(callHandleError = true): void { + console.log('Kill Server Task'); + try { + if (this.pid && this.config.setting[this.pid]) { + process.kill(this.config.setting[this.pid]); + delete this.config.setting[this.pid]; + this.stateObserver.notify(false); + this.loggerObserver.notify(`[${this.pid.toUpperCase()}-${this.config.setting[this.pid]}]: stopped`); + } + } catch (error) { + if (callHandleError) { + if (error.code === 'ESRCH') { + error.message = `ERROR: Could not terminate the process [${this.pid}]. It was not running: ${error}`; + } + this.handleError(error, false); // Pass false to prevent retrying kill in handleError + } + } + } + + public get running(): boolean { + return this.isRunning && !!this.config.setting[this.pid]; + } + + public async restart(): Promise { + console.log('Restart Server Task'); + + if (this.running) { + this.stop(); + } + + await this.start(); + } + + public stop(): void { + console.log('Stop Server Task'); + this.kill(); + } + + public async start(): Promise { + console.log('Start Server Task'); + try { + await this.runTask(this.signal); + } catch (error) { + console.error('Error starting task:', error); + this.handleError(error); + } + } + + protected handleError(error: any, attemptKill = true) { + if (attemptKill) { + this.kill(false); // Pass false to indicate that handleError should not attempt to kill again + } + this.stateObserver.notify(false); + console.error(this.errorMessage, error); + this.loggerObserver.notify(`ERROR: ${this.errorMessage} ${error}`); + } +} diff --git a/apps/server-web/src/main/helpers/services/utils/child-process-factory.ts b/apps/server-web/src/main/helpers/services/utils/child-process-factory.ts new file mode 100644 index 000000000..0556e736d --- /dev/null +++ b/apps/server-web/src/main/helpers/services/utils/child-process-factory.ts @@ -0,0 +1,15 @@ +import { ForkOptions, fork } from 'child_process'; + +export class ChildProcessFactory { + public static createProcess(path, env, signal, options?: ForkOptions) { + return fork(path, { + silent: true, + signal, + env: { + ...process.env, + ...env + }, + ...options + }); + } +} diff --git a/apps/server-web/src/main/helpers/services/utils/index.ts b/apps/server-web/src/main/helpers/services/utils/index.ts new file mode 100644 index 000000000..9a8f357af --- /dev/null +++ b/apps/server-web/src/main/helpers/services/utils/index.ts @@ -0,0 +1,2 @@ +export * from './observer'; +export * from './child-process-factory'; diff --git a/apps/server-web/src/main/helpers/services/utils/observer.ts b/apps/server-web/src/main/helpers/services/utils/observer.ts new file mode 100644 index 000000000..40fc02b81 --- /dev/null +++ b/apps/server-web/src/main/helpers/services/utils/observer.ts @@ -0,0 +1,11 @@ +export class Observer { + private callback: (data: T) => U; + + constructor(callback: (data: T) => U) { + this.callback = callback; + } + + public notify(data: T) { + this.callback(data); + } +} diff --git a/apps/server-web/src/main/helpers/services/web-service.ts b/apps/server-web/src/main/helpers/services/web-service.ts new file mode 100644 index 000000000..62bff4df2 --- /dev/null +++ b/apps/server-web/src/main/helpers/services/web-service.ts @@ -0,0 +1,48 @@ +import { BrowserWindow } from 'electron'; +import { ServerTask } from './libs/server-task'; +import { EventEmitter } from 'stream'; + +export class WebService extends ServerTask { + constructor( + readonly path: string, + readonly env: any, + readonly window: BrowserWindow, + readonly signal: AbortSignal, + readonly eventEmitter: EventEmitter + ) { + const args = { ...env, serviceName: 'WebServer' }; + + // Note: do not change this prefix because we may use it to detect the success message from the running server! + const successMessage = 'Listening at http'; + + const errorMessage = 'Error running API server:'; + + super(path, args, window, successMessage, errorMessage, signal, eventEmitter); + } + + public override async start(): Promise { + try { + this.setApiConfig(); + await super.start(); + } catch (error) { + this.handleError(error); + } + } + + public override async restart(): Promise { + try { + this.setApiConfig(); + await super.restart(); + } catch (error) { + this.handleError(error); + } + } + + private setApiConfig(): void { + // Object.assign(this.args, { + // API_HOST: '0.0.0.0', + // API_PORT: this.config.setting.PORT, + // API_BASE_URL: this.config.apiUrl + // }); + } +} diff --git a/apps/server-web/src/main/main.ts b/apps/server-web/src/main/main.ts new file mode 100644 index 000000000..e73ceb147 --- /dev/null +++ b/apps/server-web/src/main/main.ts @@ -0,0 +1,156 @@ +import path from 'path' +import { app, ipcMain, Tray, dialog } from 'electron'; +import { DesktopServer } from './helpers/desktop-server'; +import { LocalStore } from './helpers/services/libs/desktop-store'; +import { EventEmitter } from 'events'; +import { defaultTrayMenuItem, _initTray, updateTrayMenu } from './tray'; +import { EventLists } from './helpers/constant'; + +const eventEmiter = new EventEmitter(); + +const controller = new AbortController(); +const { signal } = controller; +const isPack = app.isPackaged; +const desktopServer = new DesktopServer(false, eventEmiter); +const isProd = process.env.NODE_ENV === 'production'; + +// const appPath = app.getAppPath(); + +let isServerRun: boolean; + +let tray:Tray; + +const trayMenuItems = defaultTrayMenuItem(eventEmiter); + + + +console.log(__dirname); + +if (isProd) { + // serve({ directory: 'app' }) +} else { + app.setPath('userData', `${app.getPath('userData')}`) +} + +const resourceDir = { + webServer: !isPack ? '../../release/app/dist' : '..', + resources: '../resources' +}; +const resourcesFiles = { + webServer: 'standalone/apps/web/server.js', + iconTray: 'icons/tray/icon.png' +} + +const runServer = async () => { + console.log('Run the Server...'); + try { + const envVal = getEnvApi(); + + // Instantiate API and UI servers + await desktopServer.start( + { api: path.join(__dirname, resourceDir.webServer, resourcesFiles.webServer) }, + envVal, + undefined, + signal + ); + } catch (error:any) { + if (error.name === 'AbortError') { + console.log('You exit without to stop the server'); + return; + } + } +}; + +const stopServer = async () => { + await desktopServer.stop(); +}; + +const getEnvApi = () => { + const setting = LocalStore.getStore('config') + console.log(setting); + return setting; +}; + +const onInitApplication = () => { + LocalStore.setDefaultServerConfig(); // check and set default config + tray = _initTray(resourceDir, resourcesFiles, trayMenuItems); + eventEmiter.on(EventLists.webServerStart, async () => { + updateTrayMenu('SERVER_START', { enabled: false }, eventEmiter, tray, trayMenuItems); + isServerRun = true; + await runServer(); + }) + + eventEmiter.on(EventLists.webServerStop, async () => { + isServerRun = false; + await stopServer(); + }) + + eventEmiter.on(EventLists.webServerStarted, () => { + console.log(EventLists.webServerStarted) + updateTrayMenu('SERVER_START', { enabled: false }, eventEmiter, tray, trayMenuItems); + updateTrayMenu('SERVER_STOP', { enabled: true }, eventEmiter, tray, trayMenuItems); + updateTrayMenu('SERVER_STATUS', { label: 'Status: Started' }, eventEmiter, tray, trayMenuItems); + isServerRun = true; + }) + + eventEmiter.on(EventLists.webServerStopped, () => { + console.log(EventLists.webServerStopped); + updateTrayMenu('SERVER_STOP', { enabled: false }, eventEmiter, tray, trayMenuItems); + updateTrayMenu('SERVER_START', { enabled: true }, eventEmiter, tray, trayMenuItems); + updateTrayMenu('SERVER_STATUS', { label: 'Status: Stopped' }, eventEmiter, tray, trayMenuItems); + isServerRun = false; + }) +} + + (async () => { + await app.whenReady() + onInitApplication(); + // const mainWindow = createWindow('main', { + // width: 1000, + // height: 600, + // webPreferences: { + // preload: path.join(__dirname, 'preload.js'), + // }, + // }) + + // if (isProd) { + // await mainWindow.loadURL('app://./home') + // } else { + // const port = process.argv[2] + // await mainWindow.loadURL(`http://localhost:${port}/home`) + // mainWindow.webContents.openDevTools() + // } +})() + +app.on('window-all-closed', () => { + app.quit() +}) + +ipcMain.on('message', async (event, arg) => { + event.reply('message', `${arg} World!`) +}) + +app.on('before-quit', async (e) => { + console.log('Before Quit'); + + e.preventDefault(); + + if (isServerRun) { + const exitConfirmationDialog = await dialog.showMessageBox({ + message: '', + title: 'Warning', + detail: 'Server web still running, Are you sure to exit the app ?', + buttons: [ + 'Yes', + 'No' + ] + }) + + if (exitConfirmationDialog.response === 0) { + // Stop the server from main + stopServer(); + } + } else { + app.exit(0); + } +}); diff --git a/apps/server-web/src/main/main_.ts b/apps/server-web/src/main/main_.ts new file mode 100644 index 000000000..05b44eb4f --- /dev/null +++ b/apps/server-web/src/main/main_.ts @@ -0,0 +1,137 @@ +/* eslint global-require: off, no-console: off, promise/always-return: off */ + +/** + * This module executes inside of electron's main process. You can start + * electron renderer process from here and communicate with the other processes + * through IPC. + * + * When running `npm run build` or `npm run build:main`, this file is compiled to + * `./src/main.js` using webpack. This gives us some performance wins. + */ +import path from 'path'; +import { app, BrowserWindow, shell, ipcMain } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import log from 'electron-log'; +import MenuBuilder from './menu'; +import { resolveHtmlPath } from './util'; + +class AppUpdater { + constructor() { + log.transports.file.level = 'info'; + autoUpdater.logger = log; + autoUpdater.checkForUpdatesAndNotify(); + } +} + +let mainWindow: BrowserWindow | null = null; + +ipcMain.on('ipc-example', async (event, arg) => { + const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`; + console.log(msgTemplate(arg)); + event.reply('ipc-example', msgTemplate('pong')); +}); + +if (process.env.NODE_ENV === 'production') { + const sourceMapSupport = require('source-map-support'); + sourceMapSupport.install(); +} + +const isDebug = + process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; + +if (isDebug) { + require('electron-debug')(); +} + +const installExtensions = async () => { + const installer = require('electron-devtools-installer'); + const forceDownload = !!process.env.UPGRADE_EXTENSIONS; + const extensions = ['REACT_DEVELOPER_TOOLS']; + + return installer + .default( + extensions.map((name) => installer[name]), + forceDownload, + ) + .catch(console.log); +}; + +const createWindow = async () => { + if (isDebug) { + await installExtensions(); + } + + const RESOURCES_PATH = app.isPackaged + ? path.join(process.resourcesPath, 'assets') + : path.join(__dirname, '../../assets'); + + const getAssetPath = (...paths: string[]): string => { + return path.join(RESOURCES_PATH, ...paths); + }; + + mainWindow = new BrowserWindow({ + show: false, + width: 1024, + height: 728, + icon: getAssetPath('icon.png'), + webPreferences: { + preload: app.isPackaged + ? path.join(__dirname, 'preload.js') + : path.join(__dirname, '../../.erb/dll/preload.js'), + }, + }); + + mainWindow.loadURL(resolveHtmlPath('index.html')); + + mainWindow.on('ready-to-show', () => { + if (!mainWindow) { + throw new Error('"mainWindow" is not defined'); + } + if (process.env.START_MINIMIZED) { + mainWindow.minimize(); + } else { + mainWindow.show(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + const menuBuilder = new MenuBuilder(mainWindow); + menuBuilder.buildMenu(); + + // Open urls in the user's browser + mainWindow.webContents.setWindowOpenHandler((edata) => { + shell.openExternal(edata.url); + return { action: 'deny' }; + }); + + // Remove this if your app does not use auto updates + // eslint-disable-next-line + new AppUpdater(); +}; + +/** + * Add event listeners... + */ + +app.on('window-all-closed', () => { + // Respect the OSX convention of having the application in memory even + // after all windows have been closed + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app + .whenReady() + .then(() => { + createWindow(); + app.on('activate', () => { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (mainWindow === null) createWindow(); + }); + }) + .catch(console.log); diff --git a/apps/server-web/src/main/menu.ts b/apps/server-web/src/main/menu.ts new file mode 100644 index 000000000..ba0fb7709 --- /dev/null +++ b/apps/server-web/src/main/menu.ts @@ -0,0 +1,290 @@ +import { + app, + Menu, + shell, + BrowserWindow, + MenuItemConstructorOptions, +} from 'electron'; + +interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { + selector?: string; + submenu?: DarwinMenuItemConstructorOptions[] | Menu; +} + +export default class MenuBuilder { + mainWindow: BrowserWindow; + + constructor(mainWindow: BrowserWindow) { + this.mainWindow = mainWindow; + } + + buildMenu(): Menu { + if ( + process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true' + ) { + this.setupDevelopmentEnvironment(); + } + + const template = + process.platform === 'darwin' + ? this.buildDarwinTemplate() + : this.buildDefaultTemplate(); + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + + return menu; + } + + setupDevelopmentEnvironment(): void { + this.mainWindow.webContents.on('context-menu', (_, props) => { + const { x, y } = props; + + Menu.buildFromTemplate([ + { + label: 'Inspect element', + click: () => { + this.mainWindow.webContents.inspectElement(x, y); + }, + }, + ]).popup({ window: this.mainWindow }); + }); + } + + buildDarwinTemplate(): MenuItemConstructorOptions[] { + const subMenuAbout: DarwinMenuItemConstructorOptions = { + label: 'Electron', + submenu: [ + { + label: 'About ElectronReact', + selector: 'orderFrontStandardAboutPanel:', + }, + { type: 'separator' }, + { label: 'Services', submenu: [] }, + { type: 'separator' }, + { + label: 'Hide ElectronReact', + accelerator: 'Command+H', + selector: 'hide:', + }, + { + label: 'Hide Others', + accelerator: 'Command+Shift+H', + selector: 'hideOtherApplications:', + }, + { label: 'Show All', selector: 'unhideAllApplications:' }, + { type: 'separator' }, + { + label: 'Quit', + accelerator: 'Command+Q', + click: () => { + app.quit(); + }, + }, + ], + }; + const subMenuEdit: DarwinMenuItemConstructorOptions = { + label: 'Edit', + submenu: [ + { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' }, + { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' }, + { type: 'separator' }, + { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' }, + { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' }, + { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' }, + { + label: 'Select All', + accelerator: 'Command+A', + selector: 'selectAll:', + }, + ], + }; + const subMenuViewDev: MenuItemConstructorOptions = { + label: 'View', + submenu: [ + { + label: 'Reload', + accelerator: 'Command+R', + click: () => { + this.mainWindow.webContents.reload(); + }, + }, + { + label: 'Toggle Full Screen', + accelerator: 'Ctrl+Command+F', + click: () => { + this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); + }, + }, + { + label: 'Toggle Developer Tools', + accelerator: 'Alt+Command+I', + click: () => { + this.mainWindow.webContents.toggleDevTools(); + }, + }, + ], + }; + const subMenuViewProd: MenuItemConstructorOptions = { + label: 'View', + submenu: [ + { + label: 'Toggle Full Screen', + accelerator: 'Ctrl+Command+F', + click: () => { + this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen()); + }, + }, + ], + }; + const subMenuWindow: DarwinMenuItemConstructorOptions = { + label: 'Window', + submenu: [ + { + label: 'Minimize', + accelerator: 'Command+M', + selector: 'performMiniaturize:', + }, + { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, + { type: 'separator' }, + { label: 'Bring All to Front', selector: 'arrangeInFront:' }, + ], + }; + const subMenuHelp: MenuItemConstructorOptions = { + label: 'Help', + submenu: [ + { + label: 'Learn More', + click() { + shell.openExternal('https://electronjs.org'); + }, + }, + { + label: 'Documentation', + click() { + shell.openExternal( + 'https://github.com/electron/electron/tree/main/docs#readme', + ); + }, + }, + { + label: 'Community Discussions', + click() { + shell.openExternal('https://www.electronjs.org/community'); + }, + }, + { + label: 'Search Issues', + click() { + shell.openExternal('https://github.com/electron/electron/issues'); + }, + }, + ], + }; + + const subMenuView = + process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true' + ? subMenuViewDev + : subMenuViewProd; + + return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp]; + } + + buildDefaultTemplate() { + const templateDefault = [ + { + label: '&File', + submenu: [ + { + label: '&Open', + accelerator: 'Ctrl+O', + }, + { + label: '&Close', + accelerator: 'Ctrl+W', + click: () => { + this.mainWindow.close(); + }, + }, + ], + }, + { + label: '&View', + submenu: + process.env.NODE_ENV === 'development' || + process.env.DEBUG_PROD === 'true' + ? [ + { + label: '&Reload', + accelerator: 'Ctrl+R', + click: () => { + this.mainWindow.webContents.reload(); + }, + }, + { + label: 'Toggle &Full Screen', + accelerator: 'F11', + click: () => { + this.mainWindow.setFullScreen( + !this.mainWindow.isFullScreen(), + ); + }, + }, + { + label: 'Toggle &Developer Tools', + accelerator: 'Alt+Ctrl+I', + click: () => { + this.mainWindow.webContents.toggleDevTools(); + }, + }, + ] + : [ + { + label: 'Toggle &Full Screen', + accelerator: 'F11', + click: () => { + this.mainWindow.setFullScreen( + !this.mainWindow.isFullScreen(), + ); + }, + }, + ], + }, + { + label: 'Help', + submenu: [ + { + label: 'Learn More', + click() { + shell.openExternal('https://electronjs.org'); + }, + }, + { + label: 'Documentation', + click() { + shell.openExternal( + 'https://github.com/electron/electron/tree/main/docs#readme', + ); + }, + }, + { + label: 'Community Discussions', + click() { + shell.openExternal('https://www.electronjs.org/community'); + }, + }, + { + label: 'Search Issues', + click() { + shell.openExternal('https://github.com/electron/electron/issues'); + }, + }, + ], + }, + ]; + + return templateDefault; + } +} diff --git a/apps/server-web/src/main/preload.ts b/apps/server-web/src/main/preload.ts new file mode 100644 index 000000000..7f8f10ca0 --- /dev/null +++ b/apps/server-web/src/main/preload.ts @@ -0,0 +1,20 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' + +const handler = { + send(channel: string, value: unknown) { + ipcRenderer.send(channel, value) + }, + on(channel: string, callback: (...args: unknown[]) => void) { + const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => + callback(...args) + ipcRenderer.on(channel, subscription) + + return () => { + ipcRenderer.removeListener(channel, subscription) + } + }, +} + +contextBridge.exposeInMainWorld('ipc', handler) + +export type IpcHandler = typeof handler diff --git a/apps/server-web/src/main/tray.ts b/apps/server-web/src/main/tray.ts new file mode 100644 index 000000000..861a65154 --- /dev/null +++ b/apps/server-web/src/main/tray.ts @@ -0,0 +1,68 @@ +import { app, NativeImage, nativeImage, Menu, Tray } from 'electron'; +import path from 'path'; +import { EventEmitter } from 'events'; +import { EventLists } from './helpers/constant'; + +export const _initTray = (resourceDir: any, resourcesFiles: any, contextMenu:any): Tray => { + const iconPath = path.join(__dirname, resourceDir.resources, resourcesFiles.iconTray); + console.log(iconPath) + const iconNativePath: NativeImage = nativeImage.createFromPath(iconPath); + iconNativePath.resize({ width: 16, height: 16 }) + const tray = new Tray(iconNativePath); + tray.setContextMenu(Menu.buildFromTemplate(contextMenu)); + return tray; +} + +export const defaultTrayMenuItem = (eventEmitter: EventEmitter) => { + const contextMenu = [ + { + id: 'SERVER_STATUS', + label: 'Status: Stopped', + }, + { + id: 'SERVER_START', + label: 'Start', + async click() { + eventEmitter.emit(EventLists.webServerStart); + } + }, + { + id: 'SERVER_STOP', + label: 'Stop', + async click() { + eventEmitter.emit(EventLists.webServerStop); + } + }, + { + id: 'APP_SETTING', + label: 'Settings', + async click() { + console.log('settings') + } + }, + { + id: 'APP_ABOUT', + label: 'About Gauzy Web Server', + async click() { + console.log('about') + } + }, + { + id: 'APP_QUIT', + label: 'Quit', + click() { + app.quit(); + } + } + ]; + return contextMenu; +} + +export const updateTrayMenu = (menuItem: string, context: { label?: string, enabled?: boolean}, eventEmitter: EventEmitter, tray: Tray, contextMenuItems: any) => { + const menuIdx:number = contextMenuItems.findIndex((item: any) => item.id === menuItem); + if (menuIdx > -1) { + contextMenuItems[menuIdx] = {...contextMenuItems[menuIdx], ...context}; + console.log(contextMenuItems) + tray.setContextMenu(Menu.buildFromTemplate(contextMenuItems)); + } +} diff --git a/apps/server-web/src/main/util.ts b/apps/server-web/src/main/util.ts new file mode 100644 index 000000000..7775eda37 --- /dev/null +++ b/apps/server-web/src/main/util.ts @@ -0,0 +1,13 @@ +/* eslint import/prefer-default-export: off */ +import { URL } from 'url'; +import path from 'path'; + +export function resolveHtmlPath(htmlFileName: string) { + if (process.env.NODE_ENV === 'development') { + const port = process.env.PORT || 1212; + const url = new URL(`http://localhost:${port}`); + url.pathname = htmlFileName; + return url.href; + } + return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`; +} diff --git a/apps/server-web/src/main/windows/desktop-notifier.ts b/apps/server-web/src/main/windows/desktop-notifier.ts new file mode 100644 index 000000000..81f6fe326 --- /dev/null +++ b/apps/server-web/src/main/windows/desktop-notifier.ts @@ -0,0 +1,28 @@ +import { nativeImage, Notification, NativeImage } from 'electron'; +import * as path from 'path'; + +export default class NotificationDesktop { + private readonly _iconPath: string; + private readonly _iconNativePath: NativeImage; + + constructor() { + this._iconPath = path.join(__dirname, '..', 'icons', 'icon.png'); + this._iconNativePath = nativeImage.createFromPath(this._iconPath); + this._iconNativePath.resize({ width: 16, height: 16 }); + } + + + public customNotification(message: string, title: string) { + const notification = new Notification({ + title: title, + body: message, + icon: this._iconNativePath, + closeButtonText: 'Close'//TranslateService.instant('BUTTONS.CLOSE'), + }); + + notification.show(); + setTimeout(() => { + notification.close(); + }, 3000); + } +} diff --git a/apps/server-web/src/renderer/App.css b/apps/server-web/src/renderer/App.css new file mode 100644 index 000000000..616d9a48a --- /dev/null +++ b/apps/server-web/src/renderer/App.css @@ -0,0 +1,62 @@ +/* + * @NOTE: Prepend a `~` to css file paths that are in your node_modules + * See https://github.com/webpack-contrib/sass-loader#imports + */ +body { + position: relative; + color: white; + height: 100vh; + background: linear-gradient( + 200.96deg, + #fedc2a -29.09%, + #dd5789 51.77%, + #7a2c9e 129.35% + ); + font-family: sans-serif; + overflow-y: hidden; + display: flex; + justify-content: center; + align-items: center; +} + +button { + background-color: white; + padding: 10px 20px; + border-radius: 10px; + border: none; + appearance: none; + font-size: 1.3rem; + box-shadow: 0px 8px 28px -6px rgba(24, 39, 75, 0.12), + 0px 18px 88px -4px rgba(24, 39, 75, 0.14); + transition: all ease-in 0.1s; + cursor: pointer; + opacity: 0.9; +} + +button:hover { + transform: scale(1.05); + opacity: 1; +} + +li { + list-style: none; +} + +a { + text-decoration: none; + height: fit-content; + width: fit-content; + margin: 10px; +} + +a:hover { + opacity: 1; + text-decoration: none; +} + +.Hello { + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0; +} diff --git a/apps/server-web/src/renderer/App.tsx b/apps/server-web/src/renderer/App.tsx new file mode 100644 index 000000000..43a4a7e07 --- /dev/null +++ b/apps/server-web/src/renderer/App.tsx @@ -0,0 +1,50 @@ +import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; +import icon from '../../assets/icon.svg'; +import './App.css'; + +function Hello() { + return ( +
+
+ icon +
+

electron-react-boilerplatex

+ +
+ ); +} + +export default function App() { + return ( + + + } /> + + + ); +} diff --git a/apps/server-web/src/renderer/index.ejs b/apps/server-web/src/renderer/index.ejs new file mode 100644 index 000000000..167cf37ef --- /dev/null +++ b/apps/server-web/src/renderer/index.ejs @@ -0,0 +1,14 @@ + + + + + + Hello Electron React! + + +
+ + diff --git a/apps/server-web/src/renderer/index.tsx b/apps/server-web/src/renderer/index.tsx new file mode 100644 index 000000000..064a05784 --- /dev/null +++ b/apps/server-web/src/renderer/index.tsx @@ -0,0 +1,13 @@ +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root') as HTMLElement; +const root = createRoot(container); +root.render(); + +// calling IPC exposed from preload script +window.electron.ipcRenderer.once('ipc-example', (arg) => { + // eslint-disable-next-line no-console + console.log(arg); +}); +window.electron.ipcRenderer.sendMessage('ipc-example', ['ping']); diff --git a/apps/server-web/src/renderer/preload.d.ts b/apps/server-web/src/renderer/preload.d.ts new file mode 100644 index 000000000..53cc2d7b1 --- /dev/null +++ b/apps/server-web/src/renderer/preload.d.ts @@ -0,0 +1,10 @@ +import { ElectronHandler } from '../main/preload'; + +declare global { + // eslint-disable-next-line no-unused-vars + interface Window { + electron: ElectronHandler; + } +} + +export {}; diff --git a/apps/server-web/src/resources/icon.icns b/apps/server-web/src/resources/icon.icns new file mode 100644 index 000000000..4e91309e3 Binary files /dev/null and b/apps/server-web/src/resources/icon.icns differ diff --git a/apps/server-web/src/resources/icon.ico b/apps/server-web/src/resources/icon.ico new file mode 100644 index 000000000..502f78c7a Binary files /dev/null and b/apps/server-web/src/resources/icon.ico differ diff --git a/apps/server-web/src/resources/icons/tray/icon.png b/apps/server-web/src/resources/icons/tray/icon.png new file mode 100644 index 000000000..faa960171 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@1.25x.png b/apps/server-web/src/resources/icons/tray/icon@1.25x.png new file mode 100644 index 000000000..46ea6d876 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@1.25x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@1.33x.png b/apps/server-web/src/resources/icons/tray/icon@1.33x.png new file mode 100644 index 000000000..bc73df996 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@1.33x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@1.4x.png b/apps/server-web/src/resources/icons/tray/icon@1.4x.png new file mode 100644 index 000000000..19ee320f0 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@1.4x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@1.5x.png b/apps/server-web/src/resources/icons/tray/icon@1.5x.png new file mode 100644 index 000000000..5faa32626 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@1.5x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@1.8x.png b/apps/server-web/src/resources/icons/tray/icon@1.8x.png new file mode 100644 index 000000000..0f97c50d5 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@1.8x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@2.5x.png b/apps/server-web/src/resources/icons/tray/icon@2.5x.png new file mode 100644 index 000000000..aeb2dadc3 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@2.5x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@2x.png b/apps/server-web/src/resources/icons/tray/icon@2x.png new file mode 100644 index 000000000..9d27eebae Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@2x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@3x.png b/apps/server-web/src/resources/icons/tray/icon@3x.png new file mode 100644 index 000000000..999c00be1 Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@3x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@4x.png b/apps/server-web/src/resources/icons/tray/icon@4x.png new file mode 100644 index 000000000..ff6c4fdec Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@4x.png differ diff --git a/apps/server-web/src/resources/icons/tray/icon@5x.png b/apps/server-web/src/resources/icons/tray/icon@5x.png new file mode 100644 index 000000000..da278ab3f Binary files /dev/null and b/apps/server-web/src/resources/icons/tray/icon@5x.png differ diff --git a/apps/server-web/tsconfig.json b/apps/server-web/tsconfig.json new file mode 100644 index 000000000..ccb6ee9d7 --- /dev/null +++ b/apps/server-web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "es2022", + "module": "commonjs", + "lib": ["dom", "es2022"], + "jsx": "react-jsx", + "strict": true, + "sourceMap": true, + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "allowJs": true, + "outDir": ".erb/dll" + }, + "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"] +} diff --git a/apps/web/app/[locale]/settings/team/page.tsx b/apps/web/app/[locale]/settings/team/page.tsx index f0fd961f7..9044cd372 100644 --- a/apps/web/app/[locale]/settings/team/page.tsx +++ b/apps/web/app/[locale]/settings/team/page.tsx @@ -10,20 +10,24 @@ import { userState } from '@app/stores'; import NoTeam from '@components/pages/main/no-team'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { Accordian } from 'lib/components/accordian'; import { IntegrationSetting } from 'lib/settings/integration-setting'; import { InvitationSetting } from 'lib/settings/invitation-setting'; import { IssuesSettings } from 'lib/settings/issues-settings'; import { MemberSetting } from 'lib/settings/member-setting'; +import { activeSettingTeamTab } from '@app/stores/setting'; +import { InteractionObserverVisible } from '@components/pages/setting/interaction-observer'; const Team = () => { const t = useTranslations(); + + const setActiveTeam = useSetRecoilState(activeSettingTeamTab); const [user] = useRecoilState(userState); const { isTeamMember, activeTeam } = useOrganizationTeams(); const { isTeamManager } = useIsMemberManager(user); return ( -
+
{isTeamMember ? ( <> @@ -32,57 +36,62 @@ const Team = () => { {/* General Settings */} - -
- - -
-
- - {/* Invitations */} - {isTeamManager ? ( + - +
+ + +
+
+ + {/* Invitations */} + {isTeamManager ? ( + + + + + ) : null} {/* Members */} {isTeamManager ? ( - - - + + + + + ) : null} {isTeamManager && ( + + + + + + )} + + {/* Issues Settings */} + - + - )} - - {/* Issues Settings */} - - - + {/* TODO */} {/* Notification Settings */} @@ -94,14 +103,15 @@ const Team = () => { */} {/* Danger Zone */} - - - + + + + + ) : (
diff --git a/apps/web/app/stores/setting.ts b/apps/web/app/stores/setting.ts new file mode 100644 index 000000000..28b408b70 --- /dev/null +++ b/apps/web/app/stores/setting.ts @@ -0,0 +1,6 @@ +import { atom } from "recoil"; + +export const activeSettingTeamTab = atom({ + key: 'activeSettingTeamTab', + default: '' +}); diff --git a/apps/web/components/pages/setting/interaction-observer.tsx b/apps/web/components/pages/setting/interaction-observer.tsx new file mode 100644 index 000000000..2ca5e50dc --- /dev/null +++ b/apps/web/components/pages/setting/interaction-observer.tsx @@ -0,0 +1,37 @@ +'use client'; +import { clsxm } from '@app/utils'; +import { useIntersectionObserver } from '@uidotdev/usehooks'; +import React, { useEffect } from 'react'; + +export const InteractionObserverVisible = ({ + id, + setActiveSection, + children, +}: { + id: string; + setActiveSection: (v: any) => void; + children: React.ReactNode; + className?: string; +}) => { + const [ref, entry] = useIntersectionObserver({ + threshold: 0.9, + root: null, + rootMargin: '20px' + }); + useEffect(() => { + if (entry?.isIntersecting) { + setActiveSection(id); + } + }, [entry, id, setActiveSection]); + + return ( +
+ {children} +
+
+ ); +}; diff --git a/apps/web/lib/components/image-overlapper.tsx b/apps/web/lib/components/image-overlapper.tsx index a270db41d..fd919c7ff 100644 --- a/apps/web/lib/components/image-overlapper.tsx +++ b/apps/web/lib/components/image-overlapper.tsx @@ -6,7 +6,7 @@ import { ITeamTask, ITimerStatus } from '@app/interfaces'; import Skeleton from 'react-loading-skeleton'; import { Tooltip } from './tooltip'; import { ScrollArea } from '@components/ui/scroll-bar'; -import { CircleIcon } from 'assets/svg'; +import { RiUserFill, RiUserAddFill } from "react-icons/ri"; import { useModal } from '@app/hooks'; import { Modal, Divider } from 'lib/components'; import { useOrganizationTeams } from '@app/hooks'; @@ -37,11 +37,12 @@ export default function ImageOverlapper({ radius = 20, displayImageCount = 4, item = null, - diameter = 40, + diameter = 34, iconType = false, arrowData = null, hasActiveMembers = false, assignTaskButtonCall = false, + hasInfo = '', }: { images: ImageOverlapperProps[]; radius?: number; @@ -52,6 +53,7 @@ export default function ImageOverlapper({ arrowData?: ArrowDataProps | null; hasActiveMembers?: boolean; assignTaskButtonCall?: boolean; + hasInfo?: string; }) { // Split the array into two arrays based on the display number const firstArray = images.slice(0, displayImageCount); @@ -65,6 +67,7 @@ export default function ImageOverlapper({ const [assignedMembers, setAssignedMembers] = useState([...(item?.members || [])]); const [unassignedMembers, setUnassignedMembers] = useState([]); const [validate, setValidate] = useState(false); + const [showInfo, setShowInfo] = useState(false); const t = useTranslations(); @@ -96,7 +99,15 @@ export default function ImageOverlapper({ if ((!hasMembers && item) || hasActiveMembers || assignTaskButtonCall) { return ( -
+
+ {hasInfo.length > 0 && showInfo && + (
+
+ {hasInfo} +
+
+
+ )} { iconType ? ( ) : ( - - ) + <> + { + !hasMembers ? + ( +
+ setShowInfo(true)} + onMouseOut={() => setShowInfo(false)} + /> +
+ ) + : + ( +
+ setShowInfo(true)} + onMouseOut={() => setShowInfo(false)} + /> +
+ ) + } + + ) }
diff --git a/apps/web/lib/features/task/task-assign-popover.tsx b/apps/web/lib/features/task/task-assign-popover.tsx index 59c4f68ab..79c0a440b 100644 --- a/apps/web/lib/features/task/task-assign-popover.tsx +++ b/apps/web/lib/features/task/task-assign-popover.tsx @@ -75,6 +75,7 @@ export function TaskUnOrAssignPopover({ fullWidthCombobox fullHeightCombobox autoFocus + assignTaskPopup={true} /> diff --git a/apps/web/lib/features/task/task-card.tsx b/apps/web/lib/features/task/task-card.tsx index 16ac420e8..d06e77ed9 100644 --- a/apps/web/lib/features/task/task-card.tsx +++ b/apps/web/lib/features/task/task-card.tsx @@ -124,6 +124,7 @@ export function TaskCard(props: Props) { const memberInfo = useTeamMemberCard(currentMember || undefined); const taskEdition = useTMCardTaskEdit(task); const activeMembers = task != null && task?.members?.length > 0; + const hasMembers = task?.members && task?.members?.length > 0 ; const taskAssignee: ImageOverlapperProps[] = task?.members?.map((member: any) => { return { @@ -175,6 +176,7 @@ export function TaskCard(props: Props) { images={taskAssignee} item={task} hasActiveMembers={activeMembers} + hasInfo={!hasMembers ? "Assign this task" : "Assign this task to more people"} />
)} @@ -198,14 +200,7 @@ export function TaskCard(props: Props) { className="w-11 h-11" /> )} - {!isAuthUser && task && viewType === 'unassign' && ( - - )} +
@@ -389,39 +384,7 @@ function TimerButtonCall({ ); } -function AssignTaskButtonCall({ - task, - className, - iconClassName, - taskAssignee -}: { - task: ITeamTask; - className?: string; - iconClassName?: string; - taskAssignee: ImageOverlapperProps[]; -}) { - const { - disabled, - - timerStatus, - activeTeamTask - } = useTimerView(); - - const activeTaskStatus = activeTeamTask?.id === task.id ? timerStatus : undefined; - - const arrowData = { - activeTaskStatus, - disabled, - task, - className, - iconClassName - }; - - return ; -} - //* Task Estimate info * - //* Task Info FC * function TaskInfo({ className, diff --git a/apps/web/lib/features/task/task-input.tsx b/apps/web/lib/features/task/task-input.tsx index 05e5934b3..0a320fb9a 100644 --- a/apps/web/lib/features/task/task-input.tsx +++ b/apps/web/lib/features/task/task-input.tsx @@ -63,6 +63,7 @@ type Props = { usersTaskCreatedAssignTo?: { id: string }[]; onTaskCreated?: (task: ITeamTask | undefined) => void; cardWithoutShadow?: boolean; + assignTaskPopup?: boolean; forParentChildRelationship?: boolean; } & PropsWithChildren; @@ -386,6 +387,7 @@ export function TaskInput(props: Props) { fullHeight={props.fullHeightCombobox} handleTaskCreation={handleTaskCreation} cardWithoutShadow={props.cardWithoutShadow} + assignTaskPopup={props.assignTaskPopup} updatedTaskList={updatedTaskList} forParentChildRelationship={props.forParentChildRelationship} /> @@ -436,7 +438,8 @@ function TaskCard({ handleTaskCreation, cardWithoutShadow, forParentChildRelationship, - updatedTaskList + updatedTaskList, + assignTaskPopup }: { datas: Partial; onItemClick?: (task: ITeamTask) => void; @@ -447,6 +450,7 @@ function TaskCard({ cardWithoutShadow?: boolean; forParentChildRelationship?: boolean; updatedTaskList?: ITeamTask[]; + assignTaskPopup?: boolean; }) { const [, setCount] = useState(0); const t = useTranslations(); @@ -609,7 +613,7 @@ function TaskCard({ {/* Task list */} -
    +
      {forParentChildRelationship && data?.map((task, i) => { const last = (datas.filteredTasks?.length || 0) - 1 === i; diff --git a/apps/web/lib/features/team/user-team-card/index.tsx b/apps/web/lib/features/team/user-team-card/index.tsx index 96f2f8db9..5dbcb5f38 100644 --- a/apps/web/lib/features/team/user-team-card/index.tsx +++ b/apps/web/lib/features/team/user-team-card/index.tsx @@ -174,6 +174,7 @@ export function UserTeamCard({ ? '' : memberInfo.memberUser?.id ?? '' ); + setShowActivity(false); }} className={clsxm('h-6 w-6 absolute right-4 top-0 cursor-pointer p-[3px]')} > @@ -199,7 +200,9 @@ export function UserTeamCard({ {isManagerConnectedUser != 1 ? (

      showActivityFilter('TICKET', memberInfo.member ?? null)} + onClick={() => { + showActivityFilter('TICKET', memberInfo.member ?? null); setUserDetailAccordion(''); + }} > {!showActivity ? ( @@ -251,7 +254,7 @@ export function UserTeamCard({

      {menu}
{userDetailAccordion == memberInfo.memberUser?.id && - memberInfo.memberUser.id == profile.userProfile?.id ? ( + memberInfo.memberUser.id == profile.userProfile?.id && !showActivity? (
{canSeeActivity && ( diff --git a/apps/web/lib/settings/issues-settings.tsx b/apps/web/lib/settings/issues-settings.tsx index 0f85a3728..99baef11c 100644 --- a/apps/web/lib/settings/issues-settings.tsx +++ b/apps/web/lib/settings/issues-settings.tsx @@ -9,8 +9,12 @@ import { TaskPrioritiesForm } from './task-priorities-form'; import { TaskSizesForm } from './task-sizes-form'; import { TaskStatusesForm } from './task-statuses-form'; import { DefaultIssueTypeForm } from './default-issue-type-form'; +import { useSetRecoilState } from 'recoil'; +import { activeSettingTeamTab } from '@app/stores/setting'; +import { InteractionObserverVisible } from '@components/pages/setting/interaction-observer'; export const IssuesSettings = () => { + const setActiveTeam = useSetRecoilState(activeSettingTeamTab); const t = useTranslations(); return (
@@ -166,19 +170,27 @@ export const IssuesSettings = () => {
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
-
- -
-
- -
-
- -
-
- -
{/*