start of react migration

This commit is contained in:
michalcourson
2026-02-04 20:42:14 -05:00
parent 51ad065047
commit 17bace5eaf
71 changed files with 26503 additions and 6037 deletions

12
electron-ui/.editorconfig Normal file
View File

@ -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

View File

@ -0,0 +1,7 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off"
}
}

View File

@ -0,0 +1,54 @@
/**
* 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: 'nodenext',
moduleResolution: 'nodenext',
},
},
},
},
],
},
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;

View File

@ -0,0 +1,3 @@
/* eslint import/no-unresolved: off, import/no-self-import: off */
module.exports = require('./webpack.config.renderer.dev').default;

View File

@ -0,0 +1,63 @@
/**
* Webpack config for development electron main process
*/
import path from 'path';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
// 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-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.dllPath,
filename: '[name].bundle.dev.js',
library: {
type: 'umd',
},
},
plugins: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8888,
}),
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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,42 @@
const path = require('path');
const rootPath = path.join(__dirname, '../..');
const erbPath = path.join(__dirname, '..');
const erbNodeModulesPath = path.join(erbPath, 'node_modules');
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,
erbNodeModulesPath,
dllPath,
srcPath,
srcMainPath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRendererPath,
buildPath,
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1 @@
export default 'test-file-stub';

View File

@ -0,0 +1,8 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off"
}
}

View File

@ -0,0 +1,34 @@
// Check if the renderer and main bundles are built
import path from 'path';
import chalk from 'chalk';
import fs from 'fs';
import { TextEncoder, TextDecoder } from 'node:util';
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"',
),
);
}
// JSDOM does not implement TextEncoder and TextDecoder
if (!global.TextEncoder) {
global.TextEncoder = TextEncoder;
}
if (!global.TextDecoder) {
// @ts-ignore
global.TextDecoder = TextDecoder;
}

View File

@ -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 {
console.log('Native dependencies could not be checked');
}
}

View File

@ -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);
}
}

View File

@ -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);
}
});

View File

@ -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);
});

View File

@ -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,
});
}

View File

@ -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',
});
}

View File

@ -0,0 +1,14 @@
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } =
webpackPaths;
if (fs.existsSync(appNodeModulesPath)) {
if (!fs.existsSync(srcNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
}
if (!fs.existsSync(erbNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction');
}
}

View File

@ -0,0 +1,38 @@
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_ID_PASS' in process.env &&
'APPLE_TEAM_ID' in process.env
)
) {
console.warn(
'Skipping notarizing step. APPLE_ID, APPLE_ID_PASS, and APPLE_TEAM_ID env variables must be set',
);
return;
}
const appName = context.packager.appInfo.productFilename;
await notarize({
tool: 'notarytool',
appBundleId: build.appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
teamId: process.env.APPLE_TEAM_ID,
});
};

33
electron-ui/.eslintignore Normal file
View File

@ -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

38
electron-ui/.eslintrc.js Normal file
View File

@ -0,0 +1,38 @@
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',
'react/require-default-props': 'off',
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
settings: {
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
moduleDirectory: ['node_modules', 'src/'],
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
typescript: {},
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
},
};

12
electron-ui/.gitattributes vendored Normal file
View File

@ -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

View File

@ -2,3 +2,26 @@ node_modules/
*.log
.DS_Store
dist/
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts

35
electron-ui/assets/assets.d.ts vendored Normal file
View File

@ -0,0 +1,35 @@
type Styles = Record<string, string>;
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
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;
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

Binary file not shown.

BIN
electron-ui/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
electron-ui/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,23 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_b)">
<path d="M231.5 1V0.5H231H1H0.5V1V231V231.5H1H231H231.5V231V1ZM40.5 25C40.5 33.0082 34.0082 39.5 26 39.5C17.9918 39.5 11.5 33.0082 11.5 25C11.5 16.9918 17.9918 10.5 26 10.5C34.0082 10.5 40.5 16.9918 40.5 25ZM220.5 25C220.5 33.0082 214.008 39.5 206 39.5C197.992 39.5 191.5 33.0082 191.5 25C191.5 16.9918 197.992 10.5 206 10.5C214.008 10.5 220.5 16.9918 220.5 25ZM40.5 205C40.5 213.008 34.0082 219.5 26 219.5C17.9918 219.5 11.5 213.008 11.5 205C11.5 196.992 17.9918 190.5 26 190.5C34.0082 190.5 40.5 196.992 40.5 205ZM220.5 205C220.5 213.008 214.008 219.5 206 219.5C197.992 219.5 191.5 213.008 191.5 205C191.5 196.992 197.992 190.5 206 190.5C214.008 190.5 220.5 196.992 220.5 205ZM209.5 111C209.5 162.639 167.639 204.5 116 204.5C64.3613 204.5 22.5 162.639 22.5 111C22.5 59.3613 64.3613 17.5 116 17.5C167.639 17.5 209.5 59.3613 209.5 111Z" fill="white" stroke="white"/>
<path d="M63.5 146.5C63.5 149.959 60.8969 152.5 58 152.5C55.1031 152.5 52.5 149.959 52.5 146.5C52.5 143.041 55.1031 140.5 58 140.5C60.8969 140.5 63.5 143.041 63.5 146.5Z" stroke="white" stroke-width="5"/>
<path d="M54.9856 139.466C54.9856 139.466 51.1973 116.315 83.1874 93.1647C115.178 70.014 133.698 69.5931 133.698 69.5931" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M178.902 142.686C177.27 139.853 173.652 138.88 170.819 140.512C167.987 142.144 167.014 145.762 168.646 148.595C170.277 151.427 173.896 152.4 176.728 150.768C179.561 149.137 180.534 145.518 178.902 142.686Z" stroke="white" stroke-width="5"/>
<path d="M169.409 151.555C169.409 151.555 151.24 166.394 115.211 150.232C79.182 134.07 69.5718 118.232 69.5718 118.232" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M109.577 41.9707C107.966 44.8143 108.964 48.4262 111.808 50.038C114.651 51.6498 118.263 50.6512 119.875 47.8075C121.487 44.9639 120.488 41.3521 117.645 39.7403C114.801 38.1285 111.189 39.1271 109.577 41.9707Z" stroke="white" stroke-width="5"/>
<path d="M122.038 45.6467C122.038 45.6467 144.047 53.7668 148.412 93.0129C152.778 132.259 144.012 148.579 144.012 148.579" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M59.6334 105C59.6334 105 50.4373 82.1038 61.3054 73.3616C72.1736 64.6194 96 69.1987 96 69.1987" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M149.532 66.9784C149.532 66.9784 174.391 68.9134 177.477 82.6384C180.564 96.3634 165.799 115.833 165.799 115.833" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M138.248 163.363C138.248 163.363 124.023 183.841 110.618 179.573C97.2129 175.305 87.8662 152.728 87.8662 152.728" stroke="white" stroke-width="5" stroke-linecap="round"/>
<path d="M116 119C120.418 119 124 115.642 124 111.5C124 107.358 120.418 104 116 104C111.582 104 108 107.358 108 111.5C108 115.642 111.582 119 116 119Z" fill="white"/>
</g>
<defs>
<filter id="filter0_b" x="-4" y="-4" width="240" height="240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feGaussianBlur in="BackgroundImage" stdDeviation="2"/>
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,121 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
import { ipcRenderer } from 'electron';
import path from 'path';
interface AudioTrimmerProps {
filePath: string;
section: string;
}
const AudioTrimmer: React.FC<AudioTrimmerProps> = ({ filePath, section }) => {
const [trimStart, setTrimStart] = useState<number>(0);
const [trimEnd, setTrimEnd] = useState<number>(0);
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const waveformRef = useRef<HTMLDivElement | null>(null);
const wavesurferRef = useRef<any>(null);
useEffect(() => {
const loadTrimInfo = async () => {
const savedTrimInfo = await ipcRenderer.invoke('get-trim-info', section, path.basename(filePath));
setTrimStart(savedTrimInfo.trimStart || 0);
setTrimEnd(savedTrimInfo.trimEnd || 0);
};
loadTrimInfo();
wavesurferRef.current = WaveSurfer.create({
container: waveformRef.current!,
waveColor: '#ccb1ff',
progressColor: '#6e44ba',
responsive: true,
height: 100,
hideScrollbar: true,
plugins: [
RegionsPlugin.create({
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
}),
],
});
wavesurferRef.current.load(`file://${filePath}`);
wavesurferRef.current.on('ready', () => {
wavesurferRef.current.addRegion({
start: trimStart,
end: trimEnd,
color: 'rgba(132, 81, 224, 0.3)',
});
});
wavesurferRef.current.on('region-update-end', (region: any) => {
setTrimStart(region.start);
setTrimEnd(region.end);
});
return () => {
wavesurferRef.current.destroy();
};
}, [filePath, section, trimStart, trimEnd]);
const handlePlayPause = () => {
if (isPlaying) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play(trimStart, trimEnd);
}
setIsPlaying(!isPlaying);
};
const handleSaveTrim = async () => {
const newTitle = prompt('Enter a title for the trimmed audio:');
if (newTitle) {
await ipcRenderer.invoke('save-trimmed-file', {
originalFilePath: filePath,
trimStart,
trimEnd,
title: newTitle,
});
alert('Trimmed audio saved successfully!');
}
};
const handleDelete = async () => {
const confirmDelete = confirm('Are you sure you want to delete this audio file?');
if (confirmDelete) {
await ipcRenderer.invoke('delete-file', filePath);
alert('File deleted successfully!');
}
};
return (
<div className="audio-trimmer-item">
<div className="audio-trimmer-header">
<div className="audio-trimmer-title">{path.basename(filePath)}</div>
<div className="audio-trimmer-controls">
<button onClick={handlePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<button onClick={handleSaveTrim}>Save Trim</button>
<button onClick={handleDelete}>Delete</button>
</div>
</div>
<div ref={waveformRef} className="waveform"></div>
<div className="trim-info">
<div>Start: {formatTime(trimStart)}</div>
<div>End: {formatTime(trimEnd)}</div>
</div>
</div>
);
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
export default AudioTrimmer;

View File

@ -0,0 +1,138 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import RegionsPlugin from 'wavesurfer.js/dist/plugin/wavesurfer.regions.js';
import { ipcRenderer } from 'electron';
import path from 'path';
interface AudioTrimmerProps {
filePath: string;
section: string;
}
const AudioTrimmer: React.FC<AudioTrimmerProps> = ({ filePath, section }) => {
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
const [title, setTitle] = useState('');
const waveformRef = useRef<HTMLDivElement | null>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
useEffect(() => {
const loadTrimInfo = async () => {
const savedTrimInfo = await ipcRenderer.invoke('get-trim-info', section, path.basename(filePath));
setTrimStart(savedTrimInfo.trimStart || 0);
setTrimEnd(savedTrimInfo.trimEnd || 0);
setTitle(savedTrimInfo.title || path.basename(filePath));
};
loadTrimInfo();
}, [filePath, section]);
useEffect(() => {
if (waveformRef.current) {
wavesurferRef.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: '#ccb1ff',
progressColor: '#6e44ba',
responsive: true,
height: 100,
hideScrollbar: true,
plugins: [
RegionsPlugin.create({
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
}),
],
});
wavesurferRef.current.load(`file://${filePath}`);
wavesurferRef.current.on('ready', () => {
wavesurferRef.current?.addRegion({
start: trimStart,
end: trimEnd,
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
});
wavesurferRef.current.on('region-update-end', (region) => {
setTrimStart(region.start);
setTrimEnd(region.end);
});
return () => {
wavesurferRef.current?.destroy();
};
}
}, [filePath, trimStart, trimEnd]);
const handlePlayPause = () => {
if (wavesurferRef.current) {
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play(trimStart, trimEnd);
}
}
};
const handleSaveTrim = async () => {
const newTitle = title.trim();
await ipcRenderer.invoke('save-trimmed-file', {
originalFilePath: filePath,
trimStart,
trimEnd,
title: newTitle,
});
};
const handleDelete = async () => {
const confirmDelete = window.confirm('Are you sure you want to delete this audio file?');
if (confirmDelete) {
await ipcRenderer.invoke('delete-file', filePath);
}
};
return (
<div className="audio-trimmer-item" data-filepath={filePath}>
<div className="audio-trimmer-header">
<div className="audio-trimmer-title-container">
<div className="audio-trimmer-title">{title}</div>
<div className="audio-trimmer-filename">{path.basename(filePath)}</div>
</div>
<div className="audio-trimmer-controls">
<button className="play-pause-btn" onClick={handlePlayPause}>
Play/Pause
</button>
<button className="save-trim" onClick={handleSaveTrim}>
Save
</button>
<button className="delete-btn" onClick={handleDelete}>
Delete
</button>
</div>
</div>
<div className="waveform-container" ref={waveformRef}></div>
<div className="trim-info">
<div className="trim-time">
<span>Start: </span>
<span>{formatTime(trimStart)}</span>
</div>
<div className="trim-time">
<span>End: </span>
<span>{formatTime(trimEnd)}</span>
</div>
</div>
</div>
);
};
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
export default AudioTrimmer;

View File

@ -0,0 +1,16 @@
// This file is intended for defining TypeScript types and interfaces that can be used throughout the application.
export interface TrimInfo {
title?: string;
trimStart: number;
trimEnd: number;
originalPath: string;
}
export interface AudioTrimmerProps {
filePath: string;
section: string;
savedTrimInfo: TrimInfo;
onSave: (trimInfo: TrimInfo) => void;
onDelete: () => void;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,267 @@
{
"name": "audio-clipper",
"version": "1.0.0",
"main": "src/main.js",
"dependencies": {
"chokidar": "^3.5.3",
"electron-reload": "^2.0.0-alpha.1",
"python-shell": "^5.0.0",
"wavefile": "^11.0.0",
"wavesurfer.js": "^6.6.4"
"name": "electron-react-boilerplate",
"description": "A foundation for scalable desktop apps",
"keywords": [
"electron",
"boilerplate",
"react",
"typescript",
"ts",
"sass",
"webpack",
"hot",
"reload"
],
"homepage": "https://github.com/electron-react-boilerplate/electron-react-boilerplate#readme",
"bugs": {
"url": "https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues"
},
"scripts": {
"start": "electron .",
"dev": "electron . --enable-logging",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux"
"repository": {
"type": "git",
"url": "git+https://github.com/electron-react-boilerplate/electron-react-boilerplate.git"
},
"build": {
"appId": "com.michalcourson.cliptrimserivce",
"productName": "ClipTrim",
"directories": {
"output": "dist"
"license": "MIT",
"author": {
"name": "Electron React Boilerplate Maintainers",
"email": "electronreactboilerplate@gmail.com",
"url": "https://electron-react-boilerplate.js.org"
},
"extraResources": [
"contributors": [
{
"from": "../audio-service",
"to": "audio-service",
"filter": ["**/*"]
"name": "Amila Welihinda",
"email": "amilajack@gmail.com",
"url": "https://github.com/amilajack"
}
],
"win": {
"target": ["nsis"],
"icon": "build/icon.ico"
"main": "./.erb/dll/main.bundle.dev.js",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" 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",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"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",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k -P \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon . -- {@}\" --",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest"
},
"mac": {
"target": ["dmg"],
"icon": "build/icon.icns"
},
"linux": {
"target": ["AppImage"],
"icon": "build/icon.png"
"browserslist": [
"extends browserslist-config-erb"
],
"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)$": "<rootDir>/.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"
}
},
"dependencies": {
"@electron/notarize": "^3.0.0",
"@wavesurfer/react": "^1.0.12",
"electron-debug": "^4.1.0",
"electron-log": "^5.3.2",
"electron-updater": "^6.3.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.3.0",
"wavesurfer.js": "^7.12.1"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@svgr/webpack": "^8.1.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "22.13.10",
"@types/react": "^19.0.11",
"@types/react-dom": "^19.0.4",
"@types/react-test-renderer": "^19.0.0",
"@types/webpack-bundle-analyzer": "^4.7.0",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^9.1.2",
"core-js": "^3.41.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.2",
"detect-port": "^2.1.0",
"electron": "^35.0.2",
"electron-builder": "^25.1.8",
"electron": "^13.1.7"
"electron-devtools-installer": "^4.0.0",
"electronmon": "^2.0.3",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.1.0",
"eslint-import-resolver-typescript": "^4.1.1",
"eslint-import-resolver-webpack": "^0.13.10",
"eslint-plugin-compat": "^6.0.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.9.2",
"prettier": "^3.5.3",
"react-refresh": "^0.16.0",
"react-test-renderer": "^19.0.0",
"rimraf": "^6.0.1",
"sass": "^1.86.0",
"sass-loader": "^16.0.5",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.14",
"ts-jest": "^29.2.6",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^5.8.2",
"url-loader": "^4.1.1",
"webpack": "^5.98.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.0",
"webpack-merge": "^6.0.1"
},
"build": {
"productName": "ElectronReact",
"appId": "org.erb.ElectronReact",
"asar": true,
"afterSign": ".erb/scripts/notarize.js",
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"mac": {
"notarize": false,
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
},
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
"nsis"
]
},
"linux": {
"target": [
"AppImage"
],
"category": "Development"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"extraResources": [
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "electron-react-boilerplate",
"repo": "electron-react-boilerplate"
}
},
"collective": {
"url": "https://opencollective.com/electron-react-boilerplate-594"
},
"devEngines": {
"runtime": {
"name": "node",
"version": ">=14.x",
"onFail": "error"
},
"packageManager": {
"name": "npm",
"version": ">=7.x",
"onFail": "error"
}
},
"electronmon": {
"patterns": [
"!**/**",
"src/main/**",
".erb/dll/**"
],
"logLevel": "quiet"
}
}

View File

@ -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"
}
}
}

View File

@ -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": {}
}

View File

@ -1,68 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Audio Clip Trimmer</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="titlebar"></div>
<div class="app-container">
<div class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="collections-list"></div>
<button id="add-collection-btn" class="add-collection-btn">+ New Collection</button>
</div>
<div class="sidebar-section">
<div id="nav-buttons">
<button id="settings-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
<button id="restart-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
</svg>
</button>
</div>
</div>
</div>
<div class="main-content">
<div class="audio-trimmers-section">
<div id="audio-trimmers-list" class="audio-trimmers-list">
<!-- Audio trimmers will be dynamically added here -->
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h2>Settings</h2>
<div class="settings-group">
<label for="recording-length">Recording Length (seconds):</label>
<input type="number" id="recording-length" min="1" max="300">
</div>
<div class="settings-group">
<label for="osc-port">OSC port:</label>
<input type="number" id="osc-port" min="5000" max="6000">
</div>
<div class="settings-group">
<label for="output-folder">Output Folder:</label>
<input type="text" id="output-folder" readonly>
<button id="select-output-folder">Browse</button>
</div>
<div class="settings-group">
<label for="input-device">Input Device:</label>
<select id="input-device"></select>
</div>
<button id="save-settings">Save Settings</button>
</div>
</div>
<script src="node_modules/wavesurfer.js/dist/wavesurfer.min.js"></script>
<script src="node_modules/wavesurfer.js/dist/plugin/wavesurfer.regions.js"></script>
<script src="renderer.js"></script>
</body>
</html>

View File

@ -0,0 +1,149 @@
/* 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 fs from 'fs';
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').default();
}
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' };
});
ipcMain.handle('load-audio-buffer', async (event, filePath) => {
try {
// console.log(`Loading audio file: ${filePath}`);
const buffer = fs.readFileSync(filePath);
// console.log(buffer);
return buffer;
} catch (err) {
return { error: err.message };
}
});
// 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);

View File

@ -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;
}
}

View File

@ -0,0 +1,32 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels = 'ipc-example';
const electronHandler = {
ipcRenderer: {
sendMessage(channel: Channels, ...args: unknown[]) {
ipcRenderer.send(channel, ...args);
},
on(channel: Channels, func: (...args: unknown[]) => void) {
const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
loadAudioBuffer: (filePath: string) =>
ipcRenderer.invoke('load-audio-buffer', filePath),
},
};
contextBridge.exposeInMainWorld('electron', electronHandler);
export type ElectronHandler = typeof electronHandler;

View File

@ -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)}`;
}

View File

@ -0,0 +1,74 @@
<!doctype html>
<html>
<head>
<title>Audio Clip Trimmer</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="titlebar"></div>
<div class="app-container">
<div class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="collections-list"></div>
<button id="add-collection-btn" class="add-collection-btn">
+ New Collection
</button>
</div>
<div class="sidebar-section">
<div id="nav-buttons">
<button id="settings-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"
/>
</svg>
</button>
<button id="restart-btn" class="nav-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="main-content">
<div class="audio-trimmers-section">
<div id="audio-trimmers-list" class="audio-trimmers-list">
<!-- Audio trimmers will be dynamically added here -->
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<span class="close-modal">&times;</span>
<h2>Settings</h2>
<div class="settings-group">
<label for="recording-length">Recording Length (seconds):</label>
<input type="number" id="recording-length" min="1" max="300" />
</div>
<div class="settings-group">
<label for="osc-port">OSC port:</label>
<input type="number" id="osc-port" min="5000" max="6000" />
</div>
<div class="settings-group">
<label for="output-folder">Output Folder:</label>
<input type="text" id="output-folder" readonly />
<button id="select-output-folder">Browse</button>
</div>
<div class="settings-group">
<label for="input-device">Input Device:</label>
<select id="input-device"></select>
</div>
<button id="save-settings">Save Settings</button>
</div>
</div>
<script src="node_modules/wavesurfer.js/dist/wavesurfer.min.js"></script>
<script src="node_modules/wavesurfer.js/dist/plugin/wavesurfer.regions.js"></script>
<script src="renderer.js"></script>
</body>
</html>

View File

@ -1,14 +1,14 @@
const { app, BrowserWindow, ipcMain, Tray, Menu, dialog } = require("electron");
const path = require("path");
const os = require("os");
const { app, BrowserWindow, ipcMain, Tray, Menu, dialog } = require('electron');
const path = require('path');
const os = require('os');
const spawn = require('child_process').spawn;
require("electron-reload")(__dirname);
const fs = require("fs").promises;
const chokidar = require("chokidar");
const wavefile = require("wavefile");
const MetadataManager = require("./metatadata");
require('electron-reload')(__dirname);
const fs = require('fs').promises;
const chokidar = require('chokidar');
const wavefile = require('wavefile');
const MetadataManager = require('./metatadata');
const { webContents } = require("electron");
const { webContents } = require('electron');
// import { app, BrowserWindow, ipcMain, Tray, Menu, dialog } from "electron";
// import path from "path";
@ -20,35 +20,34 @@ const { webContents } = require("electron");
// import MetadataManager from "./metatadata.cjs";
// import { webContents } from "electron";
let mainWindow;
let tray;
let audioServiceProcess;
const metadataPath = path.join(app.getPath("userData"), "audio_metadata.json");
const metadataPath = path.join(app.getPath('userData'), 'audio_metadata.json');
const metadataManager = new MetadataManager(metadataPath);
async function createPythonService() {
const pythonPath =
process.platform === "win32"
process.platform === 'win32'
? path.join(
__dirname,
"..",
"..",
"audio-service",
"venv",
"Scripts",
"python.exe"
'..',
'..',
'audio-service',
'venv',
'Scripts',
'python.exe',
)
: path.join(__dirname, "..", "audio-service", "venv", "bin", "python");
: path.join(__dirname, '..', 'audio-service', 'venv', 'bin', 'python');
const scriptPath = path.join(
__dirname,
"..",
"..",
"audio-service",
"src",
"main.py"
'..',
'..',
'audio-service',
'src',
'main.py',
);
// Load settings to pass as arguments
@ -56,50 +55,56 @@ async function createPythonService() {
const args = [
scriptPath,
'--recording-length', settings.recordingLength.toString(),
'--save-path', path.join(settings.outputFolder, "original"),
'--osc-port', settings.oscPort.toString() // Or make this configurable
'--recording-length',
settings.recordingLength.toString(),
'--save-path',
path.join(settings.outputFolder, 'original'),
'--osc-port',
settings.oscPort.toString(), // Or make this configurable
];
// Add input device if specified
if (settings.inputDevice) {
const devices = await listAudioDevices();
args.push('--input-device', devices.find(device => device.id === settings.inputDevice)?.name);
args.push(
'--input-device',
devices.find((device) => device.id === settings.inputDevice)?.name,
);
}
console.log(args)
console.log(args);
audioServiceProcess = spawn(pythonPath, args, {
detached: false,
stdio: "pipe",
stdio: 'pipe',
});
audioServiceProcess.stdout.on("data", (data) => {
audioServiceProcess.stdout.on('data', (data) => {
console.log(`Audio Service: ${data}`);
});
audioServiceProcess.stderr.on("data", (data) => {
audioServiceProcess.stderr.on('data', (data) => {
console.error(`Audio Service Error: ${data}`);
});
audioServiceProcess.on("close", (code) => {
audioServiceProcess.on('close', (code) => {
console.log(`Audio Service process exited with code ${code}`);
audioServiceProcess = null;
});
}
function createTray() {
tray = new Tray(path.join(__dirname, "assets", "tray-icon.png")); // You'll need to create this icon
tray = new Tray(path.join(__dirname, 'assets', 'tray-icon.png')); // You'll need to create this icon
const contextMenu = Menu.buildFromTemplate([
{
label: "Show",
label: 'Show',
click: () => {
mainWindow.show();
},
},
{
label: "Quit",
label: 'Quit',
click: () => {
// Properly terminate the Python service
@ -109,22 +114,22 @@ function createTray() {
},
]);
tray.setToolTip("Audio Trimmer");
tray.setToolTip('Audio Trimmer');
tray.setContextMenu(contextMenu);
}
async function checkNewWavFile(filePath) {
// Only process .wav files
if (path.extname(filePath).toLowerCase() === ".wav") {
if (path.extname(filePath).toLowerCase() === '.wav') {
try {
await metadataManager.addUntrimmedFile(filePath);
// Notify renderer if window is ready
if (mainWindow) {
mainWindow.webContents.send("new-untrimmed-file", filePath);
mainWindow.webContents.send('new-untrimmed-file', filePath);
}
} catch (error) {
console.error("Error adding untrimmed file:", error);
console.error('Error adding untrimmed file:', error);
}
}
}
@ -132,13 +137,13 @@ if (path.extname(filePath).toLowerCase() === ".wav") {
function stopService() {
if (audioServiceProcess) {
try {
if (process.platform === "win32") {
spawn("taskkill", ["/pid", audioServiceProcess.pid, "/f", "/t"]);
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
} else {
audioServiceProcess.kill("SIGTERM");
audioServiceProcess.kill('SIGTERM');
}
} catch (error) {
console.error("Error killing audio service:", error);
console.error('Error killing audio service:', error);
}
}
}
@ -153,14 +158,14 @@ function restartService() {
async function loadSettings() {
try {
const settingsPath = path.join(app.getPath("userData"), "settings.json");
const settingsData = await fs.readFile(settingsPath, "utf8");
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
const settingsData = await fs.readFile(settingsPath, 'utf8');
return JSON.parse(settingsData);
} catch (error) {
// If no settings file exists, return default settings
return {
recordingLength: 30,
outputFolder: path.join(os.homedir(), "AudioTrimmer"),
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
inputDevice: null,
};
}
@ -183,7 +188,7 @@ async function listAudioDevices() {
return devices;
} catch (error) {
console.error("Error getting input devices:", error);
console.error('Error getting input devices:', error);
return [];
}
}
@ -209,23 +214,27 @@ async function createWindow() {
// Add these to help with graphics issues
},
// These additional options can help with graphics rendering
backgroundColor: "#1e1e1e",
...(process.platform !== 'darwin' ? { titleBarOverlay: {
backgroundColor: '#1e1e1e',
...(process.platform !== 'darwin'
? {
titleBarOverlay: {
color: '#262626',
symbolColor: '#ffffff',
height: 30
} } : {})
height: 30,
},
}
: {}),
});
mainWindow.loadFile("src/index.html");
mainWindow.loadFile('src/index.html');
// Create Python ser
const settings = await loadSettings(); // Assuming you have a method to load settings
const recordingsPath = path.join(settings.outputFolder, "original");
const recordingsPath = path.join(settings.outputFolder, 'original');
// Ensure recordings directory exists
try {
await fs.mkdir(recordingsPath, { recursive: true });
} catch (error) {
console.error("Error creating recordings directory:", error);
console.error('Error creating recordings directory:', error);
}
// Watch for new WAV files
@ -245,24 +254,24 @@ async function createWindow() {
});
});
watcher.on("add", async (filePath) => {
watcher.on('add', async (filePath) => {
await checkNewWavFile(filePath);
});
ipcMain.handle("get-collections", () => {
ipcMain.handle('get-collections', () => {
return metadataManager.getCollections();
});
ipcMain.handle("get-collection-files", (event, collectionPath) => {
ipcMain.handle('get-collection-files', (event, collectionPath) => {
return metadataManager.getFilesInCollection(collectionPath);
});
ipcMain.handle("add-untrimmed-file", (event, filePath) => {
ipcMain.handle('add-untrimmed-file', (event, filePath) => {
return metadataManager.addUntrimmedFile(filePath);
});
ipcMain.handle(
"save-trimmed-file",
'save-trimmed-file',
(event, fileName, previousPath, savePath, trimStart, trimEnd, title) => {
return metadataManager.saveTrimmedFile(
fileName,
@ -270,29 +279,23 @@ async function createWindow() {
savePath,
trimStart,
trimEnd,
title
title,
);
}
},
);
ipcMain.handle(
"restart",
(event) => {
ipcMain.handle('restart', (event) => {
restartService();
}
);
});
ipcMain.handle(
"delete-old-file",
(event, outputFolder, section, title) => {
ipcMain.handle('delete-old-file', (event, outputFolder, section, title) => {
if (section === 'untrimmed') return;
const collectionPath = path.join(outputFolder, section);
const outputFilePath = path.join(collectionPath, `${title}.wav`);
fs.unlink(outputFilePath);
}
);
});
ipcMain.handle(
"save-trimmed-audio",
'save-trimmed-audio',
async (
event,
{
@ -302,7 +305,7 @@ async function createWindow() {
title,
trimStart,
trimEnd,
}
},
) => {
try {
// Ensure the collection folder exists
@ -314,7 +317,7 @@ async function createWindow() {
// Read the original WAV file
const originalWaveFile = new wavefile.WaveFile(
await fs.readFile(originalFilePath)
await fs.readFile(originalFilePath),
);
// Calculate trim points in samples
@ -353,7 +356,7 @@ async function createWindow() {
originalWaveFile.fmt.numChannels,
sampleRate,
bitDepth, // Always use 32-bit float
trimmedSamples
trimmedSamples,
);
// Write the trimmed WAV file
@ -364,84 +367,84 @@ async function createWindow() {
filePath: outputFilePath,
};
} catch (error) {
console.error("Error saving trimmed audio:", error);
console.error('Error saving trimmed audio:', error);
return {
success: false,
error: error.message,
};
}
}
},
);
ipcMain.handle("delete-file", async (event, filePath) => {
ipcMain.handle('delete-file', async (event, filePath) => {
try {
const settings = await loadSettings();
return metadataManager.deletefile(filePath, settings.outputFolder);
} catch (error) {
console.error("Error Deleting file:", error);
console.error('Error Deleting file:', error);
throw error;
}
});
ipcMain.handle("add-new-collection", (event, collectionName) => {
ipcMain.handle('add-new-collection', (event, collectionName) => {
try {
return metadataManager.addNewCollection(collectionName);
} catch (error) {
console.error("Error adding collection:", error);
console.error('Error adding collection:', error);
throw error;
}
});
ipcMain.handle("get-trim-info", (event, collectionName, filePath) => {
ipcMain.handle('get-trim-info', (event, collectionName, filePath) => {
return metadataManager.getTrimInfo(collectionName, filePath);
});
ipcMain.handle(
"set-trim-info",
'set-trim-info',
(event, collectionName, filePath, trim_info) => {
return metadataManager.setTrimInfo(collectionName, filePath, trim_info);
}
},
);
// Add these IPC handlers
ipcMain.handle("select-output-folder", async (event) => {
ipcMain.handle('select-output-folder', async (event) => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
properties: ['openDirectory'],
});
return result.filePaths[0] || "";
return result.filePaths[0] || '';
});
ipcMain.handle("get-default-settings", () => {
ipcMain.handle('get-default-settings', () => {
return {
recordingLength: 30,
outputFolder: path.join(os.homedir(), "AudioTrimmer"),
outputFolder: path.join(os.homedir(), 'AudioTrimmer'),
inputDevice: null,
};
});
ipcMain.handle("save-settings", async (event, settings) => {
ipcMain.handle('save-settings', async (event, settings) => {
try {
// Ensure output folder exists
await fs.mkdir(settings.outputFolder, { recursive: true });
// Save settings to a file
const settingsPath = path.join(app.getPath("userData"), "settings.json");
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
restartService();
return true;
} catch (error) {
console.error("Error saving settings:", error);
console.error('Error saving settings:', error);
return false;
}
});
ipcMain.handle("load-settings", async () => {
ipcMain.handle('load-settings', async () => {
return loadSettings();
});
ipcMain.handle("get-input-devices", async () => {
ipcMain.handle('get-input-devices', async () => {
return await listAudioDevices();
});
// Minimize to tray instead of closing
mainWindow.on("close", (event) => {
mainWindow.on('close', (event) => {
event.preventDefault();
mainWindow.hide();
});
@ -455,25 +458,25 @@ async function createWindow() {
app.disableHardwareAcceleration();
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
app.on('window-all-closed', () => {
// Do nothing - we handle closing via tray
});
// Ensure Python service is killed when app quits
app.on("before-quit", () => {
app.on('before-quit', () => {
if (audioServiceProcess) {
try {
if (process.platform === "win32") {
spawn("taskkill", ["/pid", audioServiceProcess.pid, "/f", "/t"]);
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', audioServiceProcess.pid, '/f', '/t']);
} else {
audioServiceProcess.kill("SIGTERM");
audioServiceProcess.kill('SIGTERM');
}
} catch (error) {
console.error("Error killing audio service:", error);
console.error('Error killing audio service:', error);
}
}
});
app.on("activate", () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}

View File

@ -1,41 +1,41 @@
const { ipcRenderer } = require("electron");
const path = require("path");
const WaveSurfer = require("wavesurfer.js");
const RegionsPlugin = require("wavesurfer.js/dist/plugin/wavesurfer.regions.js");
const { ipcRenderer } = require('electron');
// const path = require('path');
const WaveSurfer = require('wavesurfer.js');
const RegionsPlugin = require('wavesurfer.js/dist/plugin/wavesurfer.regions.js');
document.addEventListener("DOMContentLoaded", async () => {
document.addEventListener('DOMContentLoaded', async () => {
// Settings Modal Logic
const settingsModal = document.getElementById("settings-modal");
const settingsBtn = document.getElementById("settings-btn");
const restartBtn = document.getElementById("restart-btn");
const closeModalBtn = document.querySelector(".close-modal");
const saveSettingsBtn = document.getElementById("save-settings");
const selectOutputFolderBtn = document.getElementById("select-output-folder");
const recordingLengthInput = document.getElementById("recording-length");
const oscPortInput = document.getElementById("osc-port");
const outputFolderInput = document.getElementById("output-folder");
const inputDeviceSelect = document.getElementById("input-device");
const settingsModal = document.getElementById('settings-modal');
const settingsBtn = document.getElementById('settings-btn');
const restartBtn = document.getElementById('restart-btn');
const closeModalBtn = document.querySelector('.close-modal');
const saveSettingsBtn = document.getElementById('save-settings');
const selectOutputFolderBtn = document.getElementById('select-output-folder');
const recordingLengthInput = document.getElementById('recording-length');
const oscPortInput = document.getElementById('osc-port');
const outputFolderInput = document.getElementById('output-folder');
const inputDeviceSelect = document.getElementById('input-device');
// Open settings modal
settingsBtn.addEventListener("click", async () => {
settingsBtn.addEventListener('click', async () => {
try {
// Request microphone permissions first
await navigator.mediaDevices.getUserMedia({ audio: true });
// Load current settings
const settings = await ipcRenderer.invoke("load-settings");
const settings = await ipcRenderer.invoke('load-settings');
// Populate input devices
const devices = await ipcRenderer.invoke("get-input-devices");
const devices = await ipcRenderer.invoke('get-input-devices');
if (devices.length === 0) {
inputDeviceSelect.innerHTML = "<option>No microphones found</option>";
inputDeviceSelect.innerHTML = '<option>No microphones found</option>';
} else {
inputDeviceSelect.innerHTML = devices
.map(
(device) => `<option value="${device.id}">${device.name}</option>`
(device) => `<option value="${device.id}">${device.name}</option>`,
)
.join("");
.join('');
}
// Set current settings
@ -44,37 +44,37 @@ document.addEventListener("DOMContentLoaded", async () => {
inputDeviceSelect.value = settings.inputDevice;
oscPortInput.value = settings.oscPort;
settingsModal.style.display = "block";
settingsModal.style.display = 'block';
} catch (error) {
console.error("Error loading settings or devices:", error);
alert("Please grant microphone permissions to list audio devices");
console.error('Error loading settings or devices:', error);
alert('Please grant microphone permissions to list audio devices');
}
});
restartBtn.addEventListener("click", async () => {
restartBtn.addEventListener('click', async () => {
try {
await ipcRenderer.invoke("restart");
await ipcRenderer.invoke('restart');
} catch (error) {
console.error("Error restarting:", error);
alert("Failed to restart Clipper");
console.error('Error restarting:', error);
alert('Failed to restart Clipper');
}
});
// Close settings modal
closeModalBtn.addEventListener("click", () => {
settingsModal.style.display = "none";
closeModalBtn.addEventListener('click', () => {
settingsModal.style.display = 'none';
});
// Select output folder
selectOutputFolderBtn.addEventListener("click", async () => {
const folderPath = await ipcRenderer.invoke("select-output-folder");
selectOutputFolderBtn.addEventListener('click', async () => {
const folderPath = await ipcRenderer.invoke('select-output-folder');
if (folderPath) {
outputFolderInput.value = folderPath;
}
});
// Save settings
saveSettingsBtn.addEventListener("click", async () => {
saveSettingsBtn.addEventListener('click', async () => {
const settings = {
recordingLength: parseInt(recordingLengthInput.value),
oscPort: parseInt(oscPortInput.value),
@ -82,68 +82,68 @@ document.addEventListener("DOMContentLoaded", async () => {
inputDevice: inputDeviceSelect.value,
};
const saved = await ipcRenderer.invoke("save-settings", settings);
const saved = await ipcRenderer.invoke('save-settings', settings);
if (saved) {
settingsModal.style.display = "none";
settingsModal.style.display = 'none';
} else {
alert("Failed to save settings");
alert('Failed to save settings');
}
});
// Close modal if clicked outside
window.addEventListener("click", (event) => {
window.addEventListener('click', (event) => {
if (event.target === settingsModal) {
settingsModal.style.display = "none";
settingsModal.style.display = 'none';
}
});
const audioTrimmersList = document.getElementById("audio-trimmers-list");
const collectionsList = document.getElementById("collections-list");
const audioTrimmersList = document.getElementById('audio-trimmers-list');
const collectionsList = document.getElementById('collections-list');
//const currentSectionTitle = document.getElementById("current-section-title");
// Global state to persist wavesurfer instances and trimmer states
const globalState = {
wavesurferInstances: {},
trimmerStates: {},
currentSection: "untrimmed",
currentSection: 'untrimmed',
trimmerElements: {},
};
// Utility function to format time
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
// Populate collections list
async function populateCollectionsList() {
const collections = await ipcRenderer.invoke("get-collections");
const collections = await ipcRenderer.invoke('get-collections');
collectionsList.innerHTML = "";
collectionsList.innerHTML = '';
// Always add Untrimmed section first
const untrimmedItem = document.createElement("div");
untrimmedItem.classList.add("collection-item");
untrimmedItem.textContent = "Untrimmed";
untrimmedItem.dataset.collection = "untrimmed";
const untrimmedItem = document.createElement('div');
untrimmedItem.classList.add('collection-item');
untrimmedItem.textContent = 'Untrimmed';
untrimmedItem.dataset.collection = 'untrimmed';
untrimmedItem.addEventListener("click", () => {
loadCollectionFiles("untrimmed");
untrimmedItem.addEventListener('click', () => {
loadCollectionFiles('untrimmed');
});
collectionsList.appendChild(untrimmedItem);
// Add other collections
collections.forEach((collection) => {
if (collection === "untrimmed") {
if (collection === 'untrimmed') {
return;
}
const collectionItem = document.createElement("div");
collectionItem.classList.add("collection-item");
const collectionItem = document.createElement('div');
collectionItem.classList.add('collection-item');
collectionItem.textContent = collection;
collectionItem.dataset.collection = collection;
collectionItem.addEventListener("click", () => {
collectionItem.addEventListener('click', () => {
loadCollectionFiles(collection);
});
@ -169,18 +169,18 @@ document.addEventListener("DOMContentLoaded", async () => {
}
// Reset active states
document.querySelectorAll(".collection-item").forEach((el) => {
el.classList.remove("active");
document.querySelectorAll('.collection-item').forEach((el) => {
el.classList.remove('active');
});
// Set active state only for existing items
const activeItem = document.querySelector(
`.collection-item[data-collection="${collection}"]`
`.collection-item[data-collection="${collection}"]`,
);
// Only add active class if the item exists
if (activeItem) {
activeItem.classList.add("active");
activeItem.classList.add('active');
}
// Update section title and global state
@ -188,7 +188,7 @@ document.addEventListener("DOMContentLoaded", async () => {
globalState.currentSection = collection;
// Load files
const files = await ipcRenderer.invoke("get-collection-files", collection);
const files = await ipcRenderer.invoke('get-collection-files', collection);
// Add new trimmers with saved trim information
for (const file of files) {
@ -217,73 +217,73 @@ document.addEventListener("DOMContentLoaded", async () => {
}
const savedTrimInfo = await ipcRenderer.invoke(
"get-trim-info",
'get-trim-info',
globalState.currentSection,
path.basename(filePath)
path.basename(filePath),
);
// Create trimmer container
const trimmerContainer = document.createElement("div");
trimmerContainer.classList.add("audio-trimmer-item");
const trimmerContainer = document.createElement('div');
trimmerContainer.classList.add('audio-trimmer-item');
trimmerContainer.dataset.filepath = filePath;
// Create header with title and controls
const trimmerHeader = document.createElement("div");
trimmerHeader.classList.add("audio-trimmer-header");
const trimmerHeader = document.createElement('div');
trimmerHeader.classList.add('audio-trimmer-header');
// Title container
const titleContainer = document.createElement("div");
titleContainer.classList.add("audio-trimmer-title-container");
const titleContainer = document.createElement('div');
titleContainer.classList.add('audio-trimmer-title-container');
if (savedTrimInfo.title) {
// Title
const title = document.createElement("div");
title.classList.add("audio-trimmer-title");
const title = document.createElement('div');
title.classList.add('audio-trimmer-title');
title.textContent = savedTrimInfo.title;
titleContainer.appendChild(title);
// Filename
const fileName = document.createElement("div");
fileName.classList.add("audio-trimmer-filename");
const fileName = document.createElement('div');
fileName.classList.add('audio-trimmer-filename');
fileName.textContent = path.basename(filePath);
titleContainer.appendChild(fileName);
} else {
// Title (using filename if no custom title)
const title = document.createElement("div");
title.classList.add("audio-trimmer-title");
const title = document.createElement('div');
title.classList.add('audio-trimmer-title');
title.textContent = path.basename(filePath);
titleContainer.appendChild(title);
// Filename
const fileName = document.createElement("div");
fileName.classList.add("audio-trimmer-filename");
fileName.textContent = "hidden";
const fileName = document.createElement('div');
fileName.classList.add('audio-trimmer-filename');
fileName.textContent = 'hidden';
fileName.style.opacity = 0;
titleContainer.appendChild(fileName);
}
// Controls container
const controlsContainer = document.createElement("div");
controlsContainer.classList.add("audio-trimmer-controls");
const controlsContainer = document.createElement('div');
controlsContainer.classList.add('audio-trimmer-controls');
// Play/Pause and Save buttons
const playPauseBtn = document.createElement("button");
playPauseBtn.classList.add("play-pause-btn");
const playPauseBtn = document.createElement('button');
playPauseBtn.classList.add('play-pause-btn');
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
`;
const saveTrimButton = document.createElement("button");
saveTrimButton.classList.add("save-trim");
const saveTrimButton = document.createElement('button');
saveTrimButton.classList.add('save-trim');
saveTrimButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>
`;
const deletebutton = document.createElement("button");
deletebutton.classList.add("play-pause-btn");
const deletebutton = document.createElement('button');
deletebutton.classList.add('play-pause-btn');
deletebutton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
@ -300,11 +300,11 @@ document.addEventListener("DOMContentLoaded", async () => {
trimmerContainer.appendChild(trimmerHeader);
// Waveform container
const waveformContainer = document.createElement("div");
waveformContainer.classList.add("waveform-container");
const waveformContainer = document.createElement('div');
waveformContainer.classList.add('waveform-container');
const waveformId = `waveform-${path.basename(
filePath,
path.extname(filePath)
path.extname(filePath),
)}`;
waveformContainer.innerHTML = `
<div id="${waveformId}" class="waveform"></div>
@ -312,8 +312,8 @@ document.addEventListener("DOMContentLoaded", async () => {
trimmerContainer.appendChild(waveformContainer);
// Time displays
const timeInfo = document.createElement("div");
timeInfo.classList.add("trim-info");
const timeInfo = document.createElement('div');
timeInfo.classList.add('trim-info');
timeInfo.innerHTML = `
<div class="trim-time">
<span>Start: </span>
@ -344,7 +344,6 @@ document.addEventListener("DOMContentLoaded", async () => {
// const currentScroll = scrollContainer.scrollLeft;
// const containerWidth = scrollContainer.clientWidth;
// // Calculate the center point of the current view
// //const centerTime = wavesurfer.getCurrentTime();
@ -386,25 +385,25 @@ document.addEventListener("DOMContentLoaded", async () => {
// Determine the file to load (original or current)
const fileToLoad =
section === "untrimmed"
section === 'untrimmed'
? filePath
: globalState.trimmerStates[filePath]?.originalPath || filePath;
// Setup wavesurfer
const wavesurfer = WaveSurfer.create({
container: `#${waveformId}`,
waveColor: "#ccb1ff",
progressColor: "#6e44ba",
waveColor: '#ccb1ff',
progressColor: '#6e44ba',
responsive: true,
height: 100,
hideScrollbar: true,
// barWidth: 2,
// barRadius: 3,
cursorWidth: 1,
backend: "WebAudio",
backend: 'WebAudio',
plugins: [
RegionsPlugin.create({
color: "rgba(132, 81, 224, 0.3)",
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
dragSelection: {
@ -420,7 +419,6 @@ document.addEventListener("DOMContentLoaded", async () => {
],
});
// Store wavesurfer instance in global state
globalState.wavesurferInstances[filePath] = wavesurfer;
@ -433,8 +431,8 @@ document.addEventListener("DOMContentLoaded", async () => {
regionEnd: undefined,
originalPath: fileToLoad,
};
const startTimeDisplay = timeInfo.querySelector(".trim-start-time");
const endTimeDisplay = timeInfo.querySelector(".trim-end-time");
const startTimeDisplay = timeInfo.querySelector('.trim-start-time');
const endTimeDisplay = timeInfo.querySelector('.trim-end-time');
// Load audio file
wavesurfer.load(`file://${fileToLoad}`);
@ -461,7 +459,7 @@ document.addEventListener("DOMContentLoaded", async () => {
};
// When audio is ready
wavesurfer.on("ready", async () => {
wavesurfer.on('ready', async () => {
const instanceState = globalState.trimmerStates[filePath];
// Set trim times based on saved state or full duration
@ -471,7 +469,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const region = wavesurfer.addRegion({
start: instanceState.trimStart,
end: instanceState.trimEnd,
color: "rgba(132, 81, 224, 0.3)",
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
@ -483,19 +481,17 @@ document.addEventListener("DOMContentLoaded", async () => {
startTimeDisplay.textContent = formatTime(instanceState.trimStart);
endTimeDisplay.textContent = formatTime(instanceState.trimEnd);
// Store region details
instanceState.regionStart = instanceState.trimStart;
instanceState.regionEnd = instanceState.trimEnd;
// Listen for region updates
wavesurfer.on("region-update-end", async (updatedRegion) => {
wavesurfer.on('region-update-end', async (updatedRegion) => {
// Ensure the region doesn't exceed audio duration
instanceState.trimStart = Math.max(0, updatedRegion.start);
instanceState.trimEnd = Math.min(
wavesurfer.getDuration(),
updatedRegion.end
updatedRegion.end,
);
// Update time displays
@ -516,7 +512,7 @@ document.addEventListener("DOMContentLoaded", async () => {
});
// Handle region creation
wavesurfer.on("region-created", (newRegion) => {
wavesurfer.on('region-created', (newRegion) => {
// Remove all other regions
Object.keys(wavesurfer.regions.list).forEach((id) => {
if (wavesurfer.regions.list[id] !== newRegion) {
@ -526,7 +522,7 @@ document.addEventListener("DOMContentLoaded", async () => {
});
// Reset to trim start when audio finishes
wavesurfer.on("finish", () => {
wavesurfer.on('finish', () => {
wavesurfer.setCurrentTime(instanceState.trimStart);
playPauseBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@ -536,10 +532,10 @@ document.addEventListener("DOMContentLoaded", async () => {
});
// Save trimmed audio functionality
saveTrimButton.addEventListener("click", async () => {
saveTrimButton.addEventListener('click', async () => {
try {
// Get current collections
const collections = await ipcRenderer.invoke("get-collections");
const collections = await ipcRenderer.invoke('get-collections');
// Create a dialog to select or create a collection
const dialogHtml = `
@ -561,13 +557,13 @@ document.addEventListener("DOMContentLoaded", async () => {
<select id="existing-collections" style="width: 100%; margin-top: 10px;">
${collections
.map((col) =>
col === "untrimmed"
? ""
col === 'untrimmed'
? ''
: `<option value="${col}" ${
globalState.currentSection === col ? "selected" : ""
}>${col}</option>`
globalState.currentSection === col ? 'selected' : ''
}>${col}</option>`,
)
.join("")}
.join('')}
</select>
<div style="margin-top: 10px; display: flex; justify-content: space-between;">
@ -578,69 +574,67 @@ document.addEventListener("DOMContentLoaded", async () => {
`;
// Create dialog overlay
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.backgroundColor = "rgba(0,0,0,0.5)";
overlay.style.zIndex = "999";
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '999';
overlay.innerHTML = dialogHtml;
document.body.appendChild(overlay);
const existingCollectionsSelect = overlay.querySelector(
"#existing-collections"
'#existing-collections',
);
const newSaveTitleInput = overlay.querySelector("#new-save-title");
const newSaveTitleInput = overlay.querySelector('#new-save-title');
const createCollectionBtn = overlay.querySelector(
"#create-collection-btn"
'#create-collection-btn',
);
const saveToCollectionBtn = overlay.querySelector(
"#save-to-collection-btn"
'#save-to-collection-btn',
);
const cancelSaveBtn = overlay.querySelector("#cancel-save-btn");
const cancelSaveBtn = overlay.querySelector('#cancel-save-btn');
if (savedTrimInfo.title) {
newSaveTitleInput.value = savedTrimInfo.title;
}
// Save to collection
saveToCollectionBtn.addEventListener("click", async () => {
saveToCollectionBtn.addEventListener('click', async () => {
const newTitle = document
.getElementById("new-save-title")
.getElementById('new-save-title')
.value.trim();
const settings = await ipcRenderer.invoke("load-settings");
const settings = await ipcRenderer.invoke('load-settings');
const selectedCollection = existingCollectionsSelect.value;
if (!selectedCollection) {
alert("Please select or create a collection");
alert('Please select or create a collection');
return;
}
try {
await ipcRenderer.invoke(
"delete-old-file",
'delete-old-file',
settings.outputFolder,
globalState.currentSection,
savedTrimInfo.title
savedTrimInfo.title,
);
await ipcRenderer.invoke(
"save-trimmed-file",
'save-trimmed-file',
path.basename(filePath),
globalState.currentSection,
selectedCollection,
instanceState.trimStart,
instanceState.trimEnd,
newTitle
newTitle,
);
const saveResult = await ipcRenderer.invoke(
"save-trimmed-audio",
'save-trimmed-audio',
{
originalFilePath: filePath,
outputFolder: settings.outputFolder,
@ -648,7 +642,7 @@ document.addEventListener("DOMContentLoaded", async () => {
title: newTitle,
trimStart: instanceState.trimStart,
trimEnd: instanceState.trimEnd,
}
},
);
if (saveResult.success) {
@ -667,39 +661,40 @@ document.addEventListener("DOMContentLoaded", async () => {
// Refresh the view
} catch (error) {
alert("Error saving file: " + error.message);
alert('Error saving file: ' + error.message);
}
});
// Cancel button
cancelSaveBtn.addEventListener("click", () => {
cancelSaveBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
} catch (error) {
console.error("Error creating save dialog:", error);
console.error('Error creating save dialog:', error);
}
});
deletebutton.addEventListener("click", async () => {
deletebutton.addEventListener('click', async () => {
// Create confirmation dialog
const confirmDelete =
confirm(`Are you sure you want to delete this audio file?\nThis will remove the original file and any trimmed versions.`);
const confirmDelete = confirm(
`Are you sure you want to delete this audio file?\nThis will remove the original file and any trimmed versions.`,
);
if (confirmDelete) {
try {
// Delete original file
await ipcRenderer.invoke("delete-file", filePath);
await ipcRenderer.invoke('delete-file', filePath);
// Remove from UI
trimmerContainer.remove();
// Optional: Notify user
alert("File deleted successfully");
alert('File deleted successfully');
// Refresh the current section view
await loadCollectionFiles(globalState.currentSection);
await populateCollectionsList();
} catch (error) {
console.error("Error deleting file:", error);
console.error('Error deleting file:', error);
}
}
});
@ -709,13 +704,13 @@ document.addEventListener("DOMContentLoaded", async () => {
}
// Initial load of untrimmed files and collections
await loadCollectionFiles("untrimmed");
await loadCollectionFiles('untrimmed');
await populateCollectionsList();
// Listen for new untrimmed files
ipcRenderer.on("new-untrimmed-file", async (event, filePath) => {
ipcRenderer.on('new-untrimmed-file', async (event, filePath) => {
// Refresh the untrimmed section
await loadCollectionFiles("untrimmed");
await loadCollectionFiles('untrimmed');
await populateCollectionsList();
});
@ -728,8 +723,8 @@ document.addEventListener("DOMContentLoaded", async () => {
// Add collection button handler
document
.getElementById("add-collection-btn")
.addEventListener("click", async () => {
.getElementById('add-collection-btn')
.addEventListener('click', async () => {
try {
// Create a dialog to input new collection name
const dialogHtml = `
@ -760,32 +755,32 @@ document
`;
// Create dialog overlay
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100%";
overlay.style.height = "100%";
overlay.style.backgroundColor = "rgba(0,0,0,0.5)";
overlay.style.zIndex = "999";
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '999';
overlay.innerHTML = dialogHtml;
document.body.appendChild(overlay);
const newCollectionInput = overlay.querySelector("#new-collection-input");
const newCollectionInput = overlay.querySelector('#new-collection-input');
const createCollectionConfirmBtn = overlay.querySelector(
"#create-collection-confirm-btn"
'#create-collection-confirm-btn',
);
const createCollectionCancelBtn = overlay.querySelector(
"#create-collection-cancel-btn"
'#create-collection-cancel-btn',
);
// Create collection when confirm button is clicked
createCollectionConfirmBtn.addEventListener("click", async () => {
createCollectionConfirmBtn.addEventListener('click', async () => {
const newCollectionName = newCollectionInput.value.trim();
if (newCollectionName) {
try {
await ipcRenderer.invoke("add-new-collection", newCollectionName);
await ipcRenderer.invoke('add-new-collection', newCollectionName);
// Remove dialog
document.body.removeChild(overlay);
@ -794,30 +789,30 @@ document
await populateCollectionsList();
} catch (error) {
// Show error in the dialog
const errorDiv = document.createElement("div");
const errorDiv = document.createElement('div');
errorDiv.textContent = error.message;
errorDiv.style.color = "red";
errorDiv.style.marginTop = "10px";
overlay.querySelector("div").appendChild(errorDiv);
errorDiv.style.color = 'red';
errorDiv.style.marginTop = '10px';
overlay.querySelector('div').appendChild(errorDiv);
}
} else {
// Show error if input is empty
const errorDiv = document.createElement("div");
errorDiv.textContent = "Collection name cannot be empty";
errorDiv.style.color = "red";
errorDiv.style.marginTop = "10px";
overlay.querySelector("div").appendChild(errorDiv);
const errorDiv = document.createElement('div');
errorDiv.textContent = 'Collection name cannot be empty';
errorDiv.style.color = 'red';
errorDiv.style.marginTop = '10px';
overlay.querySelector('div').appendChild(errorDiv);
}
});
// Cancel button closes the dialog
createCollectionCancelBtn.addEventListener("click", () => {
createCollectionCancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
});
// Focus the input when dialog opens
newCollectionInput.focus();
} catch (error) {
console.error("Error creating new collection dialog:", error);
console.error('Error creating new collection dialog:', error);
}
});

View File

@ -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;
}

View File

@ -0,0 +1,57 @@
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import icon from '../../assets/icon.svg';
import './App.css';
import AudioTrimmer from './components/AudioTrimer';
function Hello() {
return (
<div>
{/* <div className="Hello">
<img width="200" alt="icon" src={icon} />
</div>
<h1>electron-react-boilerplate</h1>
<div className="Hello">
<a
href="https://electron-react-boilerplate.js.org/"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="books">
📚
</span>
Read our docs
</button>
</a>
<a
href="https://github.com/sponsors/electron-react-boilerplate"
target="_blank"
rel="noreferrer"
>
<button type="button">
<span role="img" aria-label="folded hands">
🙏
</span>
Donate
</button>
</a>
</div> */}
<div>
<AudioTrimmer
filePath="C:\\Users\\mickl\\Music\\clips\\original\\audio_capture_20250118_000351.wav"
// section="Section 1"
/>
</div>
</div>
);
}
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Hello />} />
</Routes>
</Router>
);
}

View File

@ -0,0 +1,235 @@
import React, { useEffect, useMemo, useState } from 'react';
// import WaveSurfer from 'wavesurfer.js';
import Regions from 'wavesurfer.js/dist/plugins/regions.esm.js';
// import { useWavesurfer } from '@wavesurfer/react';
import { BlockList } from 'net';
import WavesurferPlayer from '@wavesurfer/react';
// import { IpcRenderer } from 'electron';
export interface AudioTrimmerProps {
filePath: string;
section: string;
title?: string;
trimStart?: number;
trimEnd?: number;
onSave?: (trimStart: number, trimEnd: number, title?: string) => void;
onDelete?: () => void;
}
function getBaseName(filePath: string) {
return filePath.split(/[\\/]/).pop() || filePath;
}
export default function AudioTrimmer({ filePath }: { filePath: string }) {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [wavesurfer, setWavesurfer] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const plugins = useMemo(() => {
return [Regions.create()];
}, []);
useEffect(() => {
let url: string | null = null;
async function fetchAudio() {
// console.log('Loading audio buffer for file:', filePath);
const buffer =
await window.electron.ipcRenderer.loadAudioBuffer(filePath);
if (buffer && !buffer.error) {
const audioData = buffer.data ? new Uint8Array(buffer.data) : buffer;
url = URL.createObjectURL(new Blob([audioData]));
setBlobUrl(url);
console.log('Audio blob URL created:', url);
}
}
fetchAudio();
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [filePath]);
const onReady = (ws) => {
setWavesurfer(ws);
setIsPlaying(false);
// setDuration(ws.getDuration());
// console.log('Wavesurfer ready, duration:', ws.getDuration());
// console.log('Wavesurfer regions plugin:', ws.plugins[0]);
ws.plugins[0].addRegion?.({
start: 0,
end: ws.getDuration(),
color: 'rgba(132, 81, 224, 0.3)',
drag: false,
resize: true,
});
};
const onPlayPause = () => {
if (wavesurfer === null) return;
wavesurfer.playPause();
};
// useEffect(() => {
// if (!containerRef.current || !blobUrl) return;
// const ws = WaveSurfer.create({
// container: containerRef.current,
// waveColor: 'purple',
// url: blobUrl,
// height: 100,
// width: 600,
// });
// return () => ws.destroy();
// }, [blobUrl]);
return (
<div>
{/* <div ref={containerRef} /> */}
<WavesurferPlayer
height={100}
width={600}
url={blobUrl}
onReady={onReady}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
plugins={plugins}
/>
<button type="button" onClick={onPlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
</div>
);
}
// export default function AudioTrimmer({
// filePath,
// section,
// title,
// trimStart = 0,
// trimEnd,
// onSave,
// onDelete,
// }: AudioTrimmerProps) {
// const [wavesurfer, setWavesurfer] = useState(null);
// const waveformRef = useRef<HTMLDivElement>(null);
// const wavesurferRef = useRef<WaveSurfer | null>(null);
// const [region, setRegion] = useState<{ start: number; end: number }>({
// start: trimStart,
// end: trimEnd || 0,
// });
// // eslint-disable-next-line @typescript-eslint/no-unused-vars
// const [duration, setDuration] = useState<number>(0);
// useEffect(() => {
// if (!waveformRef.current) return;
// // const regions = Regions.create({
// // color: 'rgba(132, 81, 224, 0.3)',
// // drag: false,
// // resize: true,
// // });
// const regions = Regions.create();
// const ws = WaveSurfer.create({
// container: waveformRef.current,
// waveColor: '#ccb1ff',
// progressColor: '#6e44ba',
// // responsive: true,
// height: 100,
// hideScrollbar: true,
// backend: 'WebAudio',
// plugins: [regions],
// });
// wavesurferRef.current = ws;
// ws.load(`file://${filePath}`);
// ws.on('ready', () => {
// setDuration(ws.getDuration());
// regions.clearRegions();
// // ws.clearRegions();
// regions.addRegion({
// start: trimStart,
// end: trimEnd || ws.getDuration(),
// color: 'rgba(132, 81, 224, 0.3)',
// drag: false,
// resize: true,
// });
// });
// regions.on('region-updated', (updatedRegion: any) => {
// setRegion({
// start: Math.max(0, updatedRegion.start),
// end: Math.min(ws.getDuration(), updatedRegion.end),
// });
// });
// // eslint-disable-next-line consistent-return
// return () => {
// ws.destroy();
// };
// }, [filePath, trimStart, trimEnd]);
// const formatTime = (seconds: number) => {
// const minutes = Math.floor(seconds / 60);
// const remainingSeconds = Math.floor(seconds % 60);
// return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
// };
// return (
// <div className="audio-trimmer-item" data-filepath={filePath}>
// <div className="audio-trimmer-header">
// <div className="audio-trimmer-title-container">
// <div className="audio-trimmer-title">
// {title || getBaseName(filePath)}
// </div>
// <div className="audio-trimmer-filename">
// {title ? getBaseName(filePath) : 'hidden'}
// </div>
// <div className="audio-trimmer-section">{section}</div>
// </div>
// <div className="audio-trimmer-controls">
// <button
// type="button"
// className="play-pause-btn"
// onClick={() => {
// const ws = wavesurferRef.current;
// if (!ws) return;
// if (ws.isPlaying()) {
// ws.pause();
// } else {
// ws.play(region.start, region.end);
// }
// }}
// >
// ▶️
// </button>
// <button
// type="button"
// className="save-trim"
// onClick={() => onSave?.(region.start, region.end, title)}
// >
// 💾
// </button>
// <button type="button" className="delete-btn" onClick={onDelete}>
// 🗑️
// </button>
// </div>
// </div>
// <div className="waveform-container">
// <div ref={waveformRef} className="waveform" />
// </div>
// <div className="trim-info">
// <div className="trim-time">
// <span>Start: </span>
// <span className="trim-start-time">{formatTime(region.start)}</span>
// </div>
// <div className="trim-time">
// <span>End: </span>
// <span className="trim-end-time">{formatTime(region.end)}</span>
// </div>
// </div>
// </div>
// );
// }
// export default AudioTrimmer;

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline'"
/>
<title>Hello Electron React!</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -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(<App />);
// 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']);

10
electron-ui/src/renderer/preload.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import { ElectronHandler } from '../main/preload';
declare global {
// eslint-disable-next-line no-unused-vars
interface Window {
electron: ElectronHandler;
}
}
export {};

View File

18
electron-ui/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"incremental": true,
"target": "es2022",
"module": "node16",
"lib": ["dom", "es2022"],
"jsx": "react-jsx",
"strict": true,
"sourceMap": true,
"moduleResolution": "node16",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"allowJs": true,
"outDir": ".erb/dll"
},
"exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
}