Compare commits

...

11 Commits

Author SHA1 Message Date
Piotr Rogowski 0bc6ccc1c9
Switch to ESLint, fix some bugs (#1333) 2023-10-21 16:21:53 +02:00
Piotr Rogowski 0ebea1852f
Bump dependencies 2023-10-18 09:13:23 +02:00
Piotr Rogowski 916a3bafd4
Bump dependencies 2023-10-09 12:50:43 +02:00
dependabot[bot] 956ca6d329
Bump postcss from 8.4.29 to 8.4.31 (#1317)
Bumps [postcss](https://github.com/postcss/postcss) from 8.4.29 to 8.4.31.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.4.29...8.4.31)

---
updated-dependencies:
- dependency-name: postcss
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-08 08:31:50 +02:00
Piotr Rogowski a1e2049c6c
Enrich Sentry exceptions 2023-10-05 10:20:52 +02:00
Piotr Rogowski ff6894c16f
Refactor zoom plugins 2023-10-01 15:51:08 +02:00
Piotr Rogowski bab8af76d5
Bump packages 2023-10-01 14:49:27 +02:00
Karol Piecuch 3ea4470583
Add Keyboard zoom plugin (#1316) 2023-10-01 14:42:28 +02:00
Karol Piecuch f06382b5a1
Synchronize graphs on zoom (#1315) 2023-09-29 13:32:31 +02:00
Piotr Rogowski 10b6150a7a
Update cSpell words 2023-09-26 14:56:45 +02:00
Karol Piecuch 26e5f28119
Add Wheel Zoom plugin (#1313) 2023-09-26 13:15:36 +02:00
61 changed files with 3927 additions and 1149 deletions

View File

@ -16,11 +16,12 @@
"vscode": {
// NOTE: keep this in sync with: .vscode/extensions.json
"extensions": [
"editorconfig.editorconfig",
"davidanson.vscode-markdownlint",
"streetsidesoftware.code-spell-checker",
"biomejs.biome"
]
"editorconfig.editorconfig",
"davidanson.vscode-markdownlint",
"streetsidesoftware.code-spell-checker",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
},
// install dependencies

35
.eslintrc.cjs Normal file
View File

@ -0,0 +1,35 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'next/core-web-vitals',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'prettier',
],
plugins: ['@typescript-eslint', 'prettier'],
parser: '@typescript-eslint/parser',
root: true,
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
},
rules: {
"prettier/prettier": "error",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/no-unused-vars": "off", // handled by typescript
"react-hooks/exhaustive-deps": 'off',
"@typescript-eslint/no-empty-function": "off",
"@next/next/no-img-element": "off",
// TODO: enable later
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false,
}
],
},
};

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# automatically normalize line endings
* text=auto
# force bash scripts to always use LF line endings
*.sh text eol=lf

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
build/
coverage/
public/

10
.prettierrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"printWidth": 100,
"arrowParens": "always",
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"jsxSingleQuote": false,
"plugins": []
}

View File

@ -3,6 +3,7 @@
"editorconfig.editorconfig",
"davidanson.vscode-markdownlint",
"streetsidesoftware.code-spell-checker",
"biomejs.biome"
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

31
.vscode/settings.json vendored
View File

@ -1,22 +1,24 @@
{
"typescript.tsdk": "./node_modules/typescript/lib",
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[less]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": [
"antd",
"baro",
"biomejs",
"Codespaces",
"Datalog",
"devcontainer",
"devcontainers",
"FOME",
@ -24,13 +26,22 @@
"kbar",
"MLVLG",
"noisymime",
"pageview",
"pako",
"Piotr",
"Plottable",
"pocketbase",
"prefs",
"reduxjs",
"Rogowski",
"rusefi",
"secl",
"Sider",
"speeduino",
"tacho",
"Texpand",
"typegen",
"uplot",
"vite",
"vitejs"
]

View File

@ -1,56 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.1.2/schema.json",
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineWidth": 100,
"indentSize": 2
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingComma": "all"
}
},
"files": {
"ignore": [
".devcontainer",
".vscode",
"node_modules"
]
},
"linter": {
"enabled": true,
"rules": {
"all": true,
"correctness": {
"all": true,
"noUnusedVariables": "warn"
},
"style": {
"all": true,
"noImplicitBoolean": "off",
"useEnumInitializers": "off",
"noNonNullAssertion": "off",
"useNamingConvention": "off"
},
"suspicious": {
"all": true,
"noExplicitAny": "off"
},
"nursery": {
"all": true,
"useExhaustiveDependencies": "off",
"useImportRestrictions": "off",
"noExcessiveComplexity": "off",
"noConfusingVoidType": "off"
},
"complexity": {
"noForEach": "off"
}
}
}
}

3651
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,52 +14,57 @@
"start": "vite",
"build": "tsc && vite build && generate-version",
"serve": "vite preview",
"lint": "npm run lint:biome && tsc",
"lint:biome": "biome check src",
"lint:fix": "biome format --write src && biome check --apply src",
"lint:fix:unsafe": "biome check src --apply-unsafe src",
"lint": "npm run lint:eslint && tsc",
"lint:eslint": "eslint src",
"lint:fix": "eslint src --fix",
"analyze": "npm run build && open stats.html",
"typegen": "pocketbase-typegen --json ../cloud-backend/pb_schema.json --out src/@types/pocketbase-types.ts"
},
"dependencies": {
"@hyper-tuner/ini": "github:hyper-tuner/ini",
"@hyper-tuner/types": "github:hyper-tuner/types",
"@reduxjs/toolkit": "^1.9.5",
"@sentry/react": "^7.70.0",
"@sentry/tracing": "^7.70.0",
"@reduxjs/toolkit": "^1.9.7",
"@sentry/react": "^7.74.1",
"@sentry/tracing": "^7.74.1",
"antd": "^4.24.14",
"fuse.js": "^6.6.2",
"kbar": "^0.1.0-beta.43",
"lodash.debounce": "^4.0.8",
"mlg-converter": "^0.9.0",
"nanoid": "^5.0.1",
"nanoid": "^5.0.2",
"pako": "^2.1.0",
"pocketbase": "^0.18.0",
"pocketbase": "^0.18.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^2.1.0",
"react-markdown": "^8.0.7",
"react-markdown": "^9.0.0",
"react-perfect-scrollbar": "^1.5.8",
"react-redux": "^8.1.2",
"react-router-dom": "^6.16.0",
"react-redux": "^8.1.3",
"react-router-dom": "^6.17.0",
"react-update-notification": "^1.2.0",
"uplot": "^1.6.26",
"uplot-react": "^1.1.5",
"vite": "^4.4.9"
"vite": "^4.4.11"
},
"devDependencies": {
"@biomejs/biome": "1.2.2",
"@total-typescript/ts-reset": "^0.5.1",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^20.6.3",
"@types/pako": "^2.0.0",
"@types/react": "^18.2.22",
"@types/react-dom": "^18.2.7",
"@types/react-redux": "^7.1.26",
"@types/node": "^20.8.6",
"@types/pako": "^2.0.1",
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"@types/react-redux": "^7.1.27",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.0.4",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@vitejs/plugin-react": "^4.1.0",
"eslint": "^8.52.0",
"eslint-config-next": "^13.5.6",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"less": "^4.2.0",
"pocketbase-typegen": "^1.1.13",
"pocketbase-typegen": "^1.2.0",
"prettier": "^3.0.3",
"rollup-plugin-visualizer": "^5.9.2",
"typescript": "^5.2.2",
"vite-plugin-html": "^3.2.0",

View File

@ -2,6 +2,9 @@
* This file was @generated using pocketbase-typegen
*/
import type PocketBase from 'pocketbase';
import type { RecordService } from 'pocketbase';
export enum Collections {
IniFiles = 'iniFiles',
Stargazers = 'stargazers',
@ -126,3 +129,13 @@ export type CollectionResponses = {
tunes: TunesResponse;
users: UsersResponse;
};
// Type for usage with type asserted PocketBase instance
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = PocketBase & {
collection(idOrName: 'iniFiles'): RecordService<IniFilesResponse>;
collection(idOrName: 'stargazers'): RecordService<StargazersResponse>;
collection(idOrName: 'tunes'): RecordService<TunesResponse>;
collection(idOrName: 'users'): RecordService<UsersResponse>;
};

View File

@ -1,5 +1,6 @@
import { ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { INI } from '@hyper-tuner/ini';
import * as Sentry from '@sentry/react';
import { Layout, Modal, Result } from 'antd';
import { ReactNode, Suspense, lazy, useCallback, useEffect, useState } from 'react';
import { connect } from 'react-redux';
@ -32,7 +33,10 @@ import TuneParser from './utils/tune/TuneParser';
import 'react-perfect-scrollbar/dist/css/styles.css';
import 'uplot/dist/uPlot.min.css';
import { useAuth } from './contexts/AuthContext';
import './css/App.less';
import { isProduction } from './utils/env';
import { UpdateStatus } from 'react-update-notification/lib/types';
const Tune = lazy(() => import('./pages/Tune'));
const Logs = lazy(() => import('./pages/Logs'));
@ -64,7 +68,7 @@ const NewVersionPrompt = () => {
});
const [open, setOpen] = useState(true);
if (status === 'checking' || status === 'current') {
if (status === UpdateStatus.checking || status === UpdateStatus.current) {
return null;
}
@ -79,18 +83,20 @@ const NewVersionPrompt = () => {
onOk={reloadPage}
okText="Reload the page"
okButtonProps={{ icon: <ReloadOutlined /> }}
onCancel={() => setOpen(false)}
onCancel={() => {
setOpen(false);
}}
cancelText="I'll do it later"
cancelButtonProps={{ icon: <ClockCircleOutlined /> }}
destroyOnClose
>
<p>To enjoy the new features, it's time to refresh the page!</p>
<p>To enjoy the new features, it&apos;s time to refresh the page!</p>
<p>You can refresh later at your convenience.</p>
</Modal>
);
};
const App = ({ ui, tuneData }: { ui: UIState; tuneData: TuneDataState }) => {
const App = ({ ui, tuneData }: { ui: UIState; tuneData: TuneDataState | null }) => {
const margin = ui.sidebarCollapsed ? collapsedSidebarWidth : sidebarWidth;
const { getTune } = useDb();
const [isLoading, setIsLoading] = useState(false);
@ -98,6 +104,23 @@ const App = ({ ui, tuneData }: { ui: UIState; tuneData: TuneDataState }) => {
const tunePathMatch = useMatch(`${Routes.TUNE_ROOT}/*`);
const tuneId = tunePathMatch?.params.tuneId;
const { fetchINIFile, fetchTuneFile } = useServerStorage();
const { currentUser } = useAuth();
if (isProduction) {
if (currentUser) {
Sentry.setContext('user', {
id: currentUser.id,
email: currentUser.email,
username: currentUser.username,
});
}
if (tuneId) {
Sentry.setContext('tune', {
tuneId,
});
}
}
const loadTune = async (data: TunesResponse | null) => {
if (data === null) {
@ -173,14 +196,14 @@ const App = ({ ui, tuneData }: { ui: UIState; tuneData: TuneDataState }) => {
setIsLoading(false);
}
getTune(tuneId).then(async (tune) => {
getTune(tuneId).then((tune) => {
if (!tune) {
tuneNotFound();
navigate(Routes.HUB);
return;
}
loadTune(tune!);
loadTune(tune);
store.dispatch({
type: 'tuneData/load',
payload: JSON.parse(JSON.stringify(tune)),

View File

@ -5,7 +5,7 @@ import { Colors } from '../utils/colors';
const AuthorName = ({ author }: { author: UsersResponse }) => (
<Space>
{author.verifiedAuthor === true && (
{author.verifiedAuthor && (
<Tooltip title="Verified author">
<CheckCircleFilled style={{ color: Colors.ACCENT }} />
</Tooltip>

View File

@ -97,6 +97,7 @@ const groupNameStyle = {
opacity: 0.5,
};
// eslint-disable-next-line react/display-name
const ResultItem = forwardRef(
(
{
@ -187,11 +188,13 @@ const ResultItem = forwardRef(
const RenderResults = () => {
const { results, rootActionId } = useMatches();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onResultsRender = ({ item, active }: { item: any; active: boolean }) => {
return typeof item === 'string' ? (
<div style={groupNameStyle}>{item}</div>
) : (
<ResultItem action={item} active={active} currentRootActionId={rootActionId as string} />
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
<ResultItem action={item} active={active} currentRootActionId={rootActionId!} />
);
};
@ -213,7 +216,9 @@ const ActionsProvider = (props: CommandPaletteProps) => {
name: 'Info',
subtitle: 'Basic information about this tune.',
icon: <InfoCircleOutlined />,
perform: () => navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_ROOT)),
perform: () => {
navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_ROOT));
},
},
{
id: 'LogsAction',
@ -221,7 +226,9 @@ const ActionsProvider = (props: CommandPaletteProps) => {
name: 'Logs',
subtitle: 'Log viewer.',
icon: <FundOutlined />,
perform: () => navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_LOGS)),
perform: () => {
navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_LOGS));
},
},
{
id: 'DiagnoseAction',
@ -229,16 +236,16 @@ const ActionsProvider = (props: CommandPaletteProps) => {
name: 'Diagnose',
subtitle: 'Tooth and composite logs viewer.',
icon: <SettingOutlined />,
perform: () => navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_DIAGNOSE)),
perform: () => {
navigate(buildTuneUrl(navigation.tuneId!, Routes.TUNE_DIAGNOSE));
},
},
];
const mapSubMenuItems = (
rootMenuName: string,
rootMenu: MenuType,
subMenus: {
[name: string]: SubMenuType | GroupMenuType | GroupChildMenuType;
},
subMenus: Record<string, SubMenuType | GroupMenuType | GroupChildMenuType>,
groupMenuName: string | null = null,
) => {
Object.keys(subMenus).forEach((subMenuName: string) => {
@ -252,6 +259,7 @@ const ActionsProvider = (props: CommandPaletteProps) => {
const subMenu = subMenus[subMenuName];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if ((subMenu as GroupMenuType).type === 'groupMenu') {
// recurrence
mapSubMenuItems(
@ -273,7 +281,9 @@ const ActionsProvider = (props: CommandPaletteProps) => {
section: rootMenu.title,
name: subMenu.title,
icon: <Icon name={subMenuName} />,
perform: () => navigate(url),
perform: () => {
navigate(url);
},
});
});
};
@ -322,7 +332,9 @@ const CommandPalette = (props: CommandPaletteProps) => {
name: 'Hub',
subtitle: 'Public tunes and logs.',
icon: <CarOutlined />,
perform: () => navigate(Routes.HUB),
perform: () => {
navigate(Routes.HUB);
},
},
{
id: 'ToggleSidebar',
@ -336,7 +348,9 @@ const CommandPalette = (props: CommandPaletteProps) => {
name: 'Upload',
subtitle: 'Upload tune and logs.',
icon: <CloudUploadOutlined />,
perform: () => navigate(Routes.UPLOAD),
perform: () => {
navigate(Routes.UPLOAD);
},
},
{
id: 'LoginAction',
@ -344,7 +358,9 @@ const CommandPalette = (props: CommandPaletteProps) => {
name: 'Login',
subtitle: 'Login using email, Google or GitHub account.',
icon: <LoginOutlined />,
perform: () => navigate(Routes.LOGIN),
perform: () => {
navigate(Routes.LOGIN);
},
},
{
id: 'SignUpAction',
@ -352,7 +368,9 @@ const CommandPalette = (props: CommandPaletteProps) => {
name: 'Sign-up',
subtitle: 'Create new account.',
icon: <UserAddOutlined />,
perform: () => navigate(Routes.SIGN_UP),
perform: () => {
navigate(Routes.SIGN_UP);
},
},
{
id: 'LogoutAction',
@ -367,7 +385,9 @@ const CommandPalette = (props: CommandPaletteProps) => {
name: 'About',
subtitle: 'About this app / sponsor.',
icon: <InfoCircleOutlined />,
perform: () => navigate(Routes.ABOUT),
perform: () => {
navigate(Routes.ABOUT);
},
},
];

View File

@ -6,6 +6,8 @@ import UplotReact from 'uplot-react';
import { Colors } from '../../utils/colors';
import { colorHsl, formatNumberMs } from '../../utils/numbers';
import { isNumber } from '../../utils/tune/expression';
import keyboardZoomPlugin from '../../utils/uPlot/keyboardZoomPlugin';
import mouseZoomPlugin from '../../utils/uPlot/mouseZoomPlugin';
import touchZoomPlugin from '../../utils/uPlot/touchZoomPlugin';
export interface SelectedField {
@ -52,10 +54,11 @@ const LogCanvas = ({
const generateFieldsToPlot = useCallback(
(selectedFields: SelectedField[]) => {
const temp: { [index: string]: PlottableField } = {};
const temp: Record<string, PlottableField> = {};
data.forEach((_entry) => {
selectedFields.forEach(({ label, scale, transform, units, format }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!temp[label]) {
temp[label] = {
min: 0,
@ -76,7 +79,7 @@ const LogCanvas = ({
const generatePlotConfig = useCallback(
(
fieldsToPlot: { [index: string]: PlottableField },
fieldsToPlot: Record<string, PlottableField>,
selectedFieldsLength: number,
plotSyncKey: string,
) => {
@ -106,6 +109,7 @@ const LogCanvas = ({
}
let value = entry[label];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (value !== undefined) {
value = (value as number) * field.scale + field.transform;
}
@ -144,7 +148,7 @@ const LogCanvas = ({
sync: { key: plotSyncKey },
points: { size: 7 },
},
plugins: [touchZoomPlugin()],
plugins: [touchZoomPlugin(), mouseZoomPlugin(), keyboardZoomPlugin()],
},
};
},

View File

@ -24,7 +24,7 @@ import {
import React from 'react';
const Icon = ({ name }: { name: string }): React.JSX.Element => {
const map: { [index: string]: React.JSX.Element } = {
const map: Record<string, React.JSX.Element> = {
settings: <ControlOutlined />,
tuning: <CarOutlined />,
spark: <FireOutlined />,

View File

@ -16,39 +16,47 @@ const mapStateToProps = (state: AppState) => ({
const Firmware = ({ tune }: { tune: TuneState }) => {
const [width, setWidth] = useState(1000);
const calculateWidth = () => setWidth(window.innerWidth - 130);
const calculateWidth = () => {
setWidth(window.innerWidth - 130);
};
useEffect(() => {
calculateWidth();
window.addEventListener('resize', calculateWidth);
return () => window.removeEventListener('resize', calculateWidth);
return () => {
window.removeEventListener('resize', calculateWidth);
};
}, []);
return (
<Space>
<InfoCircleOutlined />
<Typography.Text ellipsis style={{ maxWidth: width }}>
{`${tune.details.signature} - ${tune.details.writeDate} - ${tune.details.author}`}
{[tune.details.signature, tune.details.writeDate, tune.details.author]
.filter(Boolean)
.join(' - ')}
</Typography.Text>
</Space>
);
};
const StatusBar = ({ tune }: { tune: TuneState }) => (
<Footer className="app-status-bar">
<Row>
<Col span={20}>{tune?.details?.author && <Firmware tune={tune} />}</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Link to={Routes.ABOUT}>
<Space>
<GithubOutlined />
GitHub
</Space>
</Link>
</Col>
</Row>
</Footer>
);
const StatusBar = ({ tune }: { tune: TuneState | null }) => {
return (
<Footer className="app-status-bar">
<Row>
<Col span={20}>{tune && <Firmware tune={tune} />}</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Link to={Routes.ABOUT}>
<Space>
<GithubOutlined />
GitHub
</Space>
</Link>
</Col>
</Row>
</Footer>
);
};
export default connect(mapStateToProps)(StatusBar);

View File

@ -39,17 +39,13 @@ import { buildHyperTunerAppLink } from '../utils/url';
const { Header } = Layout;
const { useBreakpoint } = Grid;
const logsExtensionsIcons: { [key: string]: React.JSX.Element } = {
const logsExtensionsIcons: Record<string, React.JSX.Element> = {
mlg: <FileZipOutlined />,
msl: <FileTextOutlined />,
csv: <FileExcelOutlined />,
};
const TopBar = ({
tuneData,
}: {
tuneData: TuneDataState | null;
}) => {
const TopBar = ({ tuneData }: { tuneData: TuneDataState | null }) => {
const { xs, sm, lg } = useBreakpoint();
const { pathname } = useLocation();
const { currentUser, logout } = useAuth();
@ -76,7 +72,9 @@ const TopBar = ({
navigate(0);
}, [logout, navigate]);
const toggleCommandPalette = useCallback(() => query.toggle(), [query]);
const toggleCommandPalette = useCallback(() => {
query.toggle();
}, [query]);
const handleGlobalKeyboard = useCallback((e: KeyboardEvent) => {
if (isToggleSidebar(e)) {
@ -146,8 +144,8 @@ const TopBar = ({
if (tuneData?.customIniFile) {
downloadFile(
Collections.Tunes,
tuneData!.id,
tuneData!.customIniFile,
tuneData.id,
tuneData.customIniFile,
downloadAnchorRef.current!,
);
} else {
@ -163,7 +161,9 @@ const TopBar = ({
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyboard);
return () => document.removeEventListener('keydown', handleGlobalKeyboard);
return () => {
document.removeEventListener('keydown', handleGlobalKeyboard);
};
}, [currentUser, handleGlobalKeyboard]);
const tabs = useMemo(
@ -181,7 +181,9 @@ const TopBar = ({
}
optionType="button"
buttonStyle="solid"
onChange={(e) => navigate(e.target.value)}
onChange={(e) => {
navigate(e.target.value as string);
}}
>
<Radio.Button value={buildTuneUrl(Routes.HUB)}>
<Space>
@ -271,7 +273,9 @@ const TopBar = ({
key: 'profile',
icon: <UserOutlined />,
label: 'Profile',
onClick: () => navigate(Routes.PROFILE),
onClick: () => {
navigate(Routes.PROFILE);
},
},
{
key: 'logout',
@ -285,13 +289,17 @@ const TopBar = ({
key: 'login',
icon: <LoginOutlined />,
label: 'Login',
onClick: () => navigate(Routes.LOGIN),
onClick: () => {
navigate(Routes.LOGIN);
},
},
{
key: 'sign-up',
icon: <UserAddOutlined />,
label: 'Sign Up',
onClick: () => navigate(Routes.SIGN_UP),
onClick: () => {
navigate(Routes.SIGN_UP);
},
},
];
}, [currentUser, logoutClick, navigate]);
@ -306,7 +314,9 @@ const TopBar = ({
key: 'about',
icon: <InfoCircleOutlined />,
label: 'About',
onClick: () => navigate(Routes.ABOUT),
onClick: () => {
navigate(Routes.ABOUT);
},
},
];

View File

@ -3,6 +3,8 @@ import uPlot from 'uplot';
import UplotReact from 'uplot-react';
import { Colors } from '../../utils/colors';
import { CompositeLogEntry } from '../../utils/logs/TriggerLogsParser';
import keyboardZoomPlugin from '../../utils/uPlot/keyboardZoomPlugin';
import mouseZoomPlugin from '../../utils/uPlot/mouseZoomPlugin';
import touchZoomPlugin from '../../utils/uPlot/touchZoomPlugin';
import LogsPagination from './LogsPagination';
@ -103,7 +105,7 @@ const CompositeCanvas = ({ data, width, height }: Props) => {
drag: { y: false },
points: { size: 7 },
},
plugins: [touchZoomPlugin()],
plugins: [touchZoomPlugin(), mouseZoomPlugin(), keyboardZoomPlugin()],
});
}, [data, width, height, indexFrom, indexTo]);

View File

@ -3,7 +3,10 @@ import uPlot from 'uplot';
import UplotReact from 'uplot-react';
import { Colors } from '../../utils/colors';
import { EntryType, ToothLogEntry } from '../../utils/logs/TriggerLogsParser';
import keyboardZoomPlugin from '../../utils/uPlot/keyboardZoomPlugin';
import mouseZoomPlugin from '../../utils/uPlot/mouseZoomPlugin';
import touchZoomPlugin from '../../utils/uPlot/touchZoomPlugin';
import LogsPagination from './LogsPagination';
const { bars } = uPlot.paths;
@ -69,7 +72,7 @@ const ToothCanvas = ({ data, width, height }: Props) => {
drag: { y: false },
points: { size: 7 },
},
plugins: [touchZoomPlugin()],
plugins: [touchZoomPlugin(), mouseZoomPlugin(), keyboardZoomPlugin()],
});
}, [data, width, height, indexFrom, indexTo]);

View File

@ -25,9 +25,7 @@ import SmartNumber from './Dialog/SmartNumber';
import SmartSelect from './Dialog/SmartSelect';
import TextField from './Dialog/TextField';
interface DialogsAndCurves {
[name: string]: DialogType | CurveType | TableType;
}
type DialogsAndCurves = Record<string, DialogType | CurveType | TableType>;
interface RenderedPanel {
type: string;
@ -72,8 +70,8 @@ const Dialog = ({
name,
}: {
ui: UIState;
config: ConfigType;
tune: TuneType;
config: ConfigType | null;
tune: TuneType | null;
name: string;
url: string;
}) => {
@ -100,8 +98,8 @@ const Dialog = ({
const renderCurve = useCallback(
(curve: CurveType) => {
const x = tune.constants[curve.xBins[0]];
const y = tune.constants[curve.yBins[0]];
const x = tune!.constants[curve.xBins[0]];
const y = tune!.constants[curve.yBins[0]];
const xConstant = findConstantOnPage(curve.xBins[0]) as ScalarConstantType;
const yConstant = findConstantOnPage(curve.yBins[0]) as ScalarConstantType;
@ -109,7 +107,7 @@ const Dialog = ({
<Curve
key={curve.yBins[0]}
// disabled={false} // TODO: evaluate condition
help={config.help[curve.yBins[0]]}
help={config!.help[curve.yBins[0]]}
xLabel={curve.labels[0]}
yLabel={curve.labels[1]}
xUnits={xConstant.units}
@ -124,9 +122,9 @@ const Dialog = ({
const renderTable = useCallback(
(table: TableType | RenderedPanel) => {
const x = tune.constants[table.xBins[0]];
const y = tune.constants[table.yBins[0]];
const z = tune.constants[table.zBins[0]];
const x = tune?.constants[table.xBins[0]];
const y = tune?.constants[table.yBins[0]];
const z = tune?.constants[table.zBins[0]];
if (!(x && y)) {
// TODO: handle this (rusEFI: fuel/lambdaTableTbl)
@ -142,20 +140,21 @@ const Dialog = ({
key={table.map}
xData={parseXy(x.value as string)}
yData={parseXy(y.value as string).reverse()}
zData={parseZ(z.value as string)}
xUnits={x.units as string}
yUnits={y.units as string}
zData={parseZ(z?.value as string)}
xUnits={x.units!}
yUnits={y.units!}
zDigits={zConstant.digits}
/>
</div>
);
},
[findConstantOnPage, tune.constants],
[findConstantOnPage, tune?.constants],
);
const resolvedDialogs: DialogsAndCurves = {};
const resolveDialogs = (source: DialogsType, dialogName: string) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!source[dialogName]) {
return;
}
@ -166,20 +165,23 @@ const Dialog = ({
Object.keys(source[dialogName].panels).forEach((panelName: string) => {
const currentDialog = source[panelName];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentDialog) {
// resolve 2D map / curve panel
if (config.curves[panelName]) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (config!.curves[panelName]) {
resolvedDialogs[panelName] = {
...config.curves[panelName],
...config!.curves[panelName],
};
return;
}
// resolve 3D map / table panel
if (config.tables[panelName]) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (config!.tables[panelName]) {
resolvedDialogs[panelName] = {
...config.tables[panelName],
...config!.tables[panelName],
};
return;
@ -192,11 +194,11 @@ const Dialog = ({
if (currentDialog.fields.length > 0) {
// resolve in root scope
resolvedDialogs[panelName] = config.dialogs[panelName];
resolvedDialogs[panelName] = config!.dialogs[panelName];
}
// recursion
resolveDialogs(config.dialogs, panelName);
resolveDialogs(config!.dialogs, panelName);
});
};
@ -212,7 +214,7 @@ const Dialog = ({
if ('fields' in currentDialog) {
type = PanelTypes.FIELDS;
fields = (currentDialog as DialogType).fields.filter((field) => field.title !== '');
fields = currentDialog.fields.filter((field) => field.title !== '');
} else if ('zBins' in currentDialog) {
type = PanelTypes.TABLE;
}
@ -243,6 +245,7 @@ const Dialog = ({
const generatePanelsComponents = useCallback(
() =>
panels.map((panel: RenderedPanel) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (panel.type === PanelTypes.FIELDS && panel.fields.length === 0) {
return null;
}
@ -252,21 +255,22 @@ const Dialog = ({
<Divider>{panel.title}</Divider>
{panel.fields.map((field: FieldType) => {
const constant = findConstantOnPage(field.name);
const tuneField = tune.constants[field.name];
const help = config.help[field.name];
const tuneField = tune!.constants[field.name];
const help = config!.help[field.name];
let input;
let enabled = true;
const fieldKey = `${panel.name}-${field.title}`;
if (field.condition) {
// TODO: optimize it
enabled = evaluateExpression(field.condition, tune.constants, config);
enabled = evaluateExpression(field.condition, tune!.constants, config!) as boolean;
}
if (field.name === '_fieldText_' && enabled) {
return <TextField key={fieldKey} title={field.title} />;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!tuneField) {
// TODO: handle this?
// name: "rpmwarn", title: "Warning",
@ -279,7 +283,7 @@ const Dialog = ({
input = (
<SmartSelect
defaultValue={`${tuneField.value}`}
values={constant.values as string[]}
values={constant.values}
disabled={!enabled}
/>
);
@ -312,7 +316,9 @@ const Dialog = ({
{field.title}
{help && (
<Popover
content={help.split('\\n').map((line) => <div key={line}>{line}</div>)}
content={help.split('\\n').map((line) => (
<div key={line}>{line}</div>
))}
>
<QuestionCircleOutlined />
</Popover>
@ -325,8 +331,8 @@ const Dialog = ({
);
})}
{panel.type === PanelTypes.CURVE && renderCurve(panel)}
{panel.type === PanelTypes.TABLE && renderTable(panel)}
{panel.type === (PanelTypes.CURVE as string) && renderCurve(panel)}
{panel.type === (PanelTypes.TABLE as string) && renderTable(panel)}
</Col>
);
}),
@ -350,7 +356,9 @@ const Dialog = ({
const tableConfig = config.tables[name];
// standalone dialog / page
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!dialogConfig) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (curveConfig) {
return (
<div ref={containerRef} className="large-container">
@ -360,6 +368,7 @@ const Dialog = ({
);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (tableConfig) {
return (
<div ref={containerRef} className="large-container">
@ -375,7 +384,7 @@ const Dialog = ({
return (
<div ref={containerRef} className="large-container">
{renderHelp(dialogConfig?.help)}
{renderHelp(dialogConfig.help)}
<Form labelCol={{ span: 10 }} wrapperCol={{ span: 10 }}>
<Row gutter={20}>{panelsComponents}</Row>
</Form>

View File

@ -21,7 +21,7 @@ const Curve = ({
yLabel: string;
xData: number[];
yData: number[];
help: string;
help?: string;
xUnits?: string;
yUnits?: string;
}) => {
@ -31,10 +31,9 @@ const Curve = ({
const [plotData, setPlotData] = useState<uPlot.AlignedData>();
const [canvasWidth, setCanvasWidth] = useState(0);
const calculateWidth = useCallback(
() => setCanvasWidth(containerRef.current?.clientWidth || 0),
[],
);
const calculateWidth = useCallback(() => {
setCanvasWidth(containerRef.current?.clientWidth || 0);
}, []);
useEffect(() => {
setPlotData([xData, yData]);
@ -77,7 +76,9 @@ const Curve = ({
calculateWidth();
window.addEventListener('resize', calculateWidth);
return () => window.removeEventListener('resize', calculateWidth);
return () => {
window.removeEventListener('resize', calculateWidth);
};
}, [xData, xLabel, xUnits, yData, yLabel, yUnits, sm, canvasWidth, calculateWidth]);
if (!sm) {
@ -86,9 +87,11 @@ const Curve = ({
return (
<>
<Typography.Paragraph>
<Typography.Text type="secondary">{help}</Typography.Text>
</Typography.Paragraph>
{help && (
<Typography.Paragraph>
<Typography.Text type="secondary">{help}</Typography.Text>
</Typography.Paragraph>
)}
<UplotReact options={options!} data={plotData!} />
<div ref={containerRef}>
<Table

View File

@ -17,7 +17,7 @@ const SmartNumber = ({
disabled: boolean;
}) => {
const isSlider = (u: string) => ['%', 'C'].includes(`${u}`.toUpperCase());
const sliderMarks: { [value: number]: string } = {};
const sliderMarks: Record<number, string> = {};
const step = digits ? 10 ** -digits : 1;
const val = formatNumber(defaultValue, digits);
sliderMarks[min] = `${min}${units}`;

View File

@ -1,7 +1,7 @@
import { Alert, Typography } from 'antd';
const TextField = ({ title }: { title: string }) => {
const types: { [char: string]: 'info' | 'warning' } = {
const types: Record<string, 'info' | 'warning' | undefined> = {
'#': 'info',
'!': 'warning',
};
@ -13,6 +13,7 @@ const TextField = ({ title }: { title: string }) => {
const urlPattern = /(?<url>https?:\/\/[^:[\]@!$'(),; ]+)/;
const matches = message.split(urlPattern);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!matches) {
return messageTag;
}
@ -20,7 +21,7 @@ const TextField = ({ title }: { title: string }) => {
const parts = matches.map((part) => {
if (urlPattern.test(part)) {
return (
<a href={part} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
<a key={part} href={part} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
{part}
</a>
);

View File

@ -100,9 +100,7 @@ const SideBar = ({
const mapSubMenuItems = useCallback(
(
rootMenuName: string,
subMenus: {
[name: string]: SubMenuType | GroupMenuType | GroupChildMenuType;
},
subMenus: Record<string, SubMenuType | GroupMenuType | GroupChildMenuType>,
groupMenuName: string | null = null,
): ItemType[] => {
const items: ItemType[] = [];
@ -122,6 +120,7 @@ const SideBar = ({
const subMenu = subMenus[subMenuName];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if ((subMenu as GroupMenuType).type === 'groupMenu') {
// recurrence
items.push({
@ -146,7 +145,9 @@ const SideBar = ({
key: url,
icon: <Icon name={subMenuName} />,
label: subMenu.title,
onClick: () => navigate(url),
onClick: () => {
navigate(url);
},
});
});

View File

@ -22,12 +22,12 @@ interface AuthValue {
logout: () => void;
initResetPassword: (email: string) => Promise<void>;
listAuthMethods: () => Promise<AuthMethodsList>;
oAuth: (provider: OAuthProviders, code: string, codeVerifier: string) => Promise<void>;
oAuth: (provider: OAuthProviders, code: string, codeVerifier: string) => void;
updateUsername: (username: string) => Promise<void>;
}
const AuthContext = createContext<AuthValue | null>(null);
// biome-ignore lint/nursery/useHookAtTopLevel: False positive
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
const useAuth = () => useContext<AuthValue>(AuthContext as any);
const users = client.collection(Collections.Users);
@ -98,7 +98,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
return Promise.reject(new Error(formatError(error as ClientResponseError)));
}
},
logout: async () => {
logout: () => {
client.authStore.clear();
},
initResetPassword: async (email: string) => {
@ -117,7 +117,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
return Promise.reject(new Error(formatError(error as ClientResponseError)));
}
},
oAuth: async (provider: OAuthProviders, code: string, codeVerifier: string) => {
oAuth: (provider: OAuthProviders, code: string, codeVerifier: string) => {
users.authWithOAuth2(
provider,
code,

View File

@ -101,6 +101,10 @@
// Radio buttons
@radio-solid-checked-color: @white;
html {
color-scheme: dark;
}
// Scrollbar
::-webkit-scrollbar {
width: 8px;

View File

@ -25,7 +25,7 @@ class BrowserStorage {
this.storage.removeItem(key);
}
public async isAvailable(): Promise<boolean> {
public isAvailable() {
return !!this.storage;
}
}
@ -37,7 +37,9 @@ const useBrowserStorage = () => {
storageGet: (key: string) => storage.get(key),
storageGetSync: (key: string) => storage.getSync(key),
storageSet: (key: string, value: string) => storage.set(key, value),
storageDelete: (key: string) => storage.delete(key),
storageDelete: (key: string) => {
storage.delete(key);
},
};
};

View File

@ -10,9 +10,10 @@ import { useMemo } from 'react';
const findConstantOnPage = (config: ConfigType, fieldName: string): Constant => {
const foundPage =
config.constants.pages.find((page: PageType) => fieldName in page.data) ||
config.constants.pages.find((page: PageType) => fieldName in page.data) ??
({ data: {} } as PageType);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!foundPage) {
throw new Error(`Constant [${fieldName}] not found`);
}
@ -22,6 +23,8 @@ const findConstantOnPage = (config: ConfigType, fieldName: string): Constant =>
const findOutputChannel = (config: ConfigType, name: string): OutputChannel | SimpleConstant => {
const result = config.outputChannels[name];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!result) {
throw new Error(`Output channel [${name}] not found`);
}
@ -42,6 +45,8 @@ const findDatalogNameByLabel = (config: ConfigType, label: string): string => {
const findDatalog = (config: ConfigType, name: string): DatalogEntry => {
const result = config.datalog[name];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!result) {
throw new Error(`Datalog entry [${name}] not found`);
}
@ -50,7 +55,6 @@ const findDatalog = (config: ConfigType, name: string): DatalogEntry => {
};
const useConfig = (config: ConfigType | null) =>
// biome-ignore lint/nursery/useHookAtTopLevel: False positive
useMemo(
() => ({
isConfigReady: !!config?.constants,

View File

@ -21,7 +21,7 @@ if (isProduction) {
});
if (import.meta.env.VITE_GTM_ID) {
ReactGA.initialize(import.meta.env.VITE_GTM_ID);
ReactGA.initialize(import.meta.env.VITE_GTM_ID as string);
ReactGA.send({ hitType: 'pageview', page: window.location.hash });
}
}

View File

@ -41,7 +41,7 @@ const Diagnose = ({
tuneData,
}: {
ui: UIState;
loadedToothLogs: ToothLogsState;
loadedToothLogs: ToothLogsState | null;
tuneData: TuneDataState | null;
}) => {
const { lg } = Grid.useBreakpoint();
@ -85,7 +85,7 @@ const Diagnose = ({
}
// user didn't upload any logs
if (tuneData && (tuneData.toothLogFiles || []).length === 0) {
if ((tuneData?.toothLogFiles || []).length === 0) {
navigate(Routes.HUB);
return;
@ -141,12 +141,12 @@ const Diagnose = ({
};
// first visit, logs are not loaded yet
if (!loadedToothLogs.type && tuneData?.tuneId) {
if (!loadedToothLogs?.type && tuneData?.tuneId) {
loadData();
}
// file changed, reload
if (loadedToothLogs.type && loadedToothLogs.fileName !== routeMatch?.params.fileName) {
if (loadedToothLogs?.type && loadedToothLogs.fileName !== routeMatch?.params.fileName) {
// setToothLogs(undefined);
// setCompositeLogs(undefined);
store.dispatch({ type: 'toothLogs/load', payload: {} });
@ -154,9 +154,9 @@ const Diagnose = ({
}
// user navigated to logs root page
if (!routeMatch?.params.fileName && tuneData && tuneData.toothLogFiles?.length) {
if (!routeMatch?.params.fileName && tuneData && tuneData.toothLogFiles.length) {
// either redirect to the first log or to the latest selected
if (loadedToothLogs.fileName) {
if (loadedToothLogs?.fileName) {
navigate(
generatePath(Routes.TUNE_DIAGNOSE_FILE, {
tuneId: tuneData.tuneId,
@ -164,7 +164,7 @@ const Diagnose = ({
}),
);
} else {
const firstLogFile = (tuneData.toothLogFiles || [])[0];
const firstLogFile = tuneData.toothLogFiles[0];
navigate(
generatePath(Routes.TUNE_DIAGNOSE_FILE, {
tuneId: tuneData.tuneId,
@ -185,7 +185,7 @@ const Diagnose = ({
}, [calculateCanvasSize, routeMatch?.params.fileName, ui.sidebarCollapsed, tuneData?.tuneId]);
const Graph = () => {
switch (loadedToothLogs.type) {
switch (loadedToothLogs?.type) {
case 'composite':
return (
<CompositeCanvas
@ -206,7 +206,7 @@ const Diagnose = ({
return (
<>
<Sider {...siderProps} className="app-sidebar">
{loadedToothLogs.type ? (
{loadedToothLogs?.type ? (
!ui.sidebarCollapsed && (
<Tabs
defaultActiveKey="files"
@ -217,7 +217,7 @@ const Diagnose = ({
<Badge
size="small"
style={badgeStyle}
count={tuneData?.toothLogFiles?.length}
count={tuneData?.toothLogFiles.length}
offset={[10, -3]}
>
<FileTextOutlined />
@ -227,7 +227,7 @@ const Diagnose = ({
key: 'files',
children: (
<PerfectScrollbar options={{ suppressScrollX: true }}>
{tuneData?.toothLogFiles?.map((fileName) => (
{tuneData?.toothLogFiles.map((fileName) => (
<Typography.Paragraph key={fileName} ellipsis>
<Link
to={generatePath(Routes.TUNE_DIAGNOSE_FILE, {
@ -255,7 +255,7 @@ const Diagnose = ({
<Layout className="logs-container">
<Content>
<div ref={contentRef}>
{loadedToothLogs.type ? (
{loadedToothLogs?.type ? (
<Graph />
) : (
<Space direction="vertical" size="large">
@ -269,7 +269,7 @@ const Diagnose = ({
title: 'Downloading',
subTitle: fileSize,
description: fetchError ? (
fetchError!.message
fetchError.message
) : (
<Space>
<GlobalOutlined />

View File

@ -34,12 +34,13 @@ const Hub = () => {
const [total, setTotal] = useState(0);
const searchRef = useRef<InputRef | null>(null);
const { currentUser } = useAuth();
const goToEdit = (tuneId: string) =>
const goToEdit = (tuneId: string) => {
navigate(
generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId,
}),
);
};
const loadData = debounce(async (searchText: string) => {
setIsLoading(true);
@ -53,6 +54,7 @@ const Hub = () => {
aspiration: aspirationMapper[tune.aspiration],
updated: formatTime(tune.updated),
}));
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
setDataSource(mapped as any);
} catch (_error) {
// request cancelled
@ -83,10 +85,12 @@ const Hub = () => {
// searchRef.current?.focus(); // autofocus
return () => window.removeEventListener('keydown', handleGlobalKeyboard);
return () => {
window.removeEventListener('keydown', handleGlobalKeyboard);
};
}, [page]);
const columns: ColumnsType<any> = [
const columns: ColumnsType<TunesResponse<TunesResponseExpand>> = [
{
title: 'Tunes',
render: (tune: TunesResponse<TunesResponseExpand>) => (
@ -119,7 +123,7 @@ const Hub = () => {
dataIndex: 'vehicleName',
key: 'vehicleName',
responsive: ['sm'],
render: (vehicleName: string, tune: TunesResponse) => (
render: (vehicleName: string, tune) => (
<Space direction="vertical">
{vehicleName}
<TuneTag tag={tune.tags} />
@ -161,7 +165,7 @@ const Hub = () => {
dataIndex: 'authorUsername',
key: 'authorUsername',
responsive: ['sm'],
render: (_userName: string, record: TunesResponse<TunesResponseExpand>) => (
render: (_userName: string, record) => (
<Link to={generatePath(Routes.USER_ROOT, { userId: record.author })}>
<AuthorName author={record.expand!.author} />
</Link>
@ -198,20 +202,30 @@ const Hub = () => {
return (
<Space>
{isOwner && (
<Button size={size} icon={<EditOutlined />} onClick={() => goToEdit(tuneId)} />
<Button
size={size}
icon={<EditOutlined />}
onClick={() => {
goToEdit(tuneId);
}}
/>
)}
{isClipboardSupported && (
<Button
size={size}
icon={<CopyOutlined />}
onClick={() => copyToClipboard(buildFullUrl([tunePath(tuneId)]))}
onClick={() => {
copyToClipboard(buildFullUrl([tunePath(tuneId)]));
}}
/>
)}
<Button
size={size}
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => navigate(tunePath(tuneId))}
onClick={() => {
navigate(tunePath(tuneId));
}}
/>
</Space>
);
@ -223,18 +237,20 @@ const Hub = () => {
return (
<div className="large-container">
<Input
// biome-ignore lint: make search input first in tab order
tabIndex={1}
ref={searchRef}
style={{ marginBottom: 10, height: 40 }}
value={searchQuery}
placeholder="Search by anything..."
onChange={({ target }) => debounceLoadData(target.value)}
onChange={({ target }) => {
debounceLoadData(target.value);
}}
allowClear
/>
<Table
dataSource={dataSource}
columns={columns}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
columns={columns as any}
loading={isLoading}
scroll={xs ? undefined : { x: 1360 }}
pagination={false}

View File

@ -19,16 +19,19 @@ const mapStateToProps = (state: AppState) => ({
tuneData: state.tuneData,
});
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
const Info = ({ tuneData }: { tuneData: TuneDataState | null }) => {
const navigate = useNavigate();
const { currentUser } = useAuth();
const goToEdit = () =>
navigate(
generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId: tuneData.tuneId,
}),
);
const goToEdit = () => {
if (tuneData) {
navigate(
generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId: tuneData.tuneId,
}),
);
}
};
const canManage = currentUser && tuneData && currentUser.id === tuneData.author;
@ -76,32 +79,32 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
<Row {...rowProps}>
<Col span={24} sm={24}>
<Item>
<Input value={tuneData.vehicleName!} addonBefore="Vehicle name" />
<Input value={tuneData.vehicleName} addonBefore="Vehicle name" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.engineMake!} addonBefore="Engine make" />
<Input value={tuneData.engineMake} addonBefore="Engine make" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.engineCode!} addonBefore="Engine code" />
<Input value={tuneData.engineCode} addonBefore="Engine code" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.displacement!} addonBefore="Displacement" addonAfter="l" />
<Input value={tuneData.displacement} addonBefore="Displacement" addonAfter="l" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input
value={tuneData.cylindersCount!}
value={tuneData.cylindersCount}
addonBefore="Cylinders"
style={{ width: '100%' }}
/>
@ -129,7 +132,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
<Col {...colProps}>
<Item>
<Input
value={tuneData.compression!}
value={tuneData.compression}
addonBefore="Compression"
style={{ width: '100%' }}
addonAfter=":1"
@ -140,36 +143,36 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.fuel!} addonBefore="Fuel" />
<Input value={tuneData.fuel} addonBefore="Fuel" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.ignition!} addonBefore="Ignition" />
<Input value={tuneData.ignition} addonBefore="Ignition" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.injectorsSize!} addonBefore="Injectors size" addonAfter="cc" />
<Input value={tuneData.injectorsSize} addonBefore="Injectors size" addonAfter="cc" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.year!} addonBefore="Year" />
<Input value={tuneData.year} addonBefore="Year" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.hp!} addonBefore="HP" style={{ width: '100%' }} />
<Input value={tuneData.hp} addonBefore="HP" style={{ width: '100%' }} />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.stockHp!} addonBefore="Stock HP" style={{ width: '100%' }} />
<Input value={tuneData.stockHp} addonBefore="Stock HP" style={{ width: '100%' }} />
</Item>
</Col>
</Row>

View File

@ -73,8 +73,8 @@ const Logs = ({
tuneData,
}: {
ui: UIState;
config: ConfigState;
loadedLogs: LogsState;
config: ConfigState | null;
loadedLogs: LogsState | null;
tuneData: TuneDataState | null;
}) => {
const { lg } = Grid.useBreakpoint();
@ -148,7 +148,7 @@ const Logs = ({
return [];
}
return Object.values(config.datalog)
return Object.values(config?.datalog || {})
.map((entry: DatalogEntry) => {
const { units, scale, transform } = findOutputChannel(entry.name) as OutputChannel;
const { name, label, format } = entry;
@ -175,11 +175,11 @@ const Logs = ({
const [foundFields2, setFoundFields2] = useState<DatalogEntry[]>([]);
const fuse = new Fuse<DatalogEntry>(fields, fuseOptions);
const debounceSearch1 = debounce(async (searchText: string) => {
const debounceSearch1 = debounce((searchText: string) => {
const result = fuse.search(searchText);
setFoundFields1(result.length > 0 ? result.map((item) => item.item) : fields);
}, 300);
const debounceSearch2 = debounce(async (searchText: string) => {
const debounceSearch2 = debounce((searchText: string) => {
const result = fuse.search(searchText);
setFoundFields2(result.length > 0 ? result.map((item) => item.item) : fields);
}, 300);
@ -190,10 +190,10 @@ const Logs = ({
const { signal } = controller;
const loadData = async () => {
const logFileName = routeMatch?.params.fileName!;
const logFileName = routeMatch?.params.fileName ?? '';
// user didn't upload any logs
if (tuneData && (tuneData.logFiles || []).length === 0) {
if (tuneData && tuneData.logFiles.length === 0) {
navigate(Routes.HUB);
return;
@ -236,7 +236,7 @@ const Logs = ({
}
case 'metrics': {
setParseElapsed(msToTime(data.elapsed!));
setSamplesCount(data.records!);
setSamplesCount(data.records);
setStep(2);
break;
}
@ -258,9 +258,9 @@ const Logs = ({
};
// user navigated to logs root page
if (!routeMatch?.params.fileName && tuneData && tuneData.logFiles?.length) {
if (!routeMatch?.params.fileName && tuneData && tuneData.logFiles.length) {
// either redirect to the first log or to the latest selected
if (loadedLogs.fileName) {
if (loadedLogs?.fileName) {
navigate(
generatePath(Routes.TUNE_LOGS_FILE, {
tuneId: tuneData.tuneId,
@ -268,7 +268,7 @@ const Logs = ({
}),
);
} else {
const firstLogFile = (tuneData.logFiles || [])[0];
const firstLogFile = tuneData.logFiles[0];
navigate(
generatePath(Routes.TUNE_LOGS_FILE, {
tuneId: tuneData.tuneId,
@ -281,12 +281,12 @@ const Logs = ({
}
// first visit, logs are not loaded yet
if (!(loadedLogs.logs || []).length && tuneData?.tuneId) {
if (!(loadedLogs?.logs || []).length && tuneData?.tuneId) {
loadData();
}
// file changed, reload
if ((loadedLogs.logs || []).length && loadedLogs.fileName !== routeMatch?.params.fileName) {
if ((loadedLogs?.logs || []).length && loadedLogs?.fileName !== routeMatch?.params.fileName) {
setLogs(undefined);
store.dispatch({ type: 'logs/load', payload: {} });
loadData();
@ -400,7 +400,7 @@ const Logs = ({
<Badge
size="small"
style={badgeStyle}
count={tuneData?.logFiles?.length}
count={tuneData?.logFiles.length}
offset={[10, -3]}
>
<FileTextOutlined />
@ -410,7 +410,7 @@ const Logs = ({
key: 'files',
children: (
<PerfectScrollbar options={{ suppressScrollX: true }}>
{tuneData?.logFiles?.map((fileName) => (
{tuneData?.logFiles.map((fileName) => (
<Typography.Paragraph key={fileName} ellipsis>
<Link
to={generatePath(Routes.TUNE_LOGS_FILE, {
@ -438,9 +438,9 @@ const Logs = ({
<Layout className="logs-container">
<Content>
<div ref={contentRef}>
{logs || !!(loadedLogs.logs || []).length ? (
{logs || !!(loadedLogs?.logs || []).length ? (
<LogCanvas
data={loadedLogs.logs || (logs!.records as LogsType)}
data={loadedLogs?.logs || (logs!.records as LogsType)}
width={canvasWidth}
height={canvasHeight}
selectedFields1={prepareSelectedFields(selectedFields1)}
@ -464,7 +464,7 @@ const Logs = ({
title: 'Downloading',
subTitle: fileSize,
description: fetchError ? (
fetchError!.message
fetchError.message
) : (
<Space>
<GlobalOutlined />
@ -475,7 +475,7 @@ const Logs = ({
},
{
title: 'Decoding',
description: parseError ? parseError!.message : 'Reading ones and zeros',
description: parseError ? parseError.message : 'Reading ones and zeros',
subTitle: parseElapsed,
status: parseError && 'error',
},

View File

@ -16,7 +16,7 @@ const mapStateToProps = (state: AppState) => ({
tune: state.tune,
});
const Tune = ({ config, tune }: { config: ConfigType | null; tune: TuneState }) => {
const Tune = ({ config, tune }: { config: ConfigType | null; tune: TuneState | null }) => {
const dialogMatch = useMatch(Routes.TUNE_DIALOG);
const tuneRootMatch = useMatch(Routes.TUNE_TUNE);
const groupMenuDialogMatch = useMatch(Routes.TUNE_GROUP_MENU_DIALOG);
@ -28,8 +28,8 @@ const Tune = ({ config, tune }: { config: ConfigType | null; tune: TuneState })
useEffect(() => {
if (tune && config && tuneRootMatch && tuneId) {
const firstCategory = Object.keys(config!.menus)[0];
const firstDialog = Object.keys(config!.menus[firstCategory].subMenus)[0];
const firstCategory = Object.keys(config.menus)[0];
const firstDialog = Object.keys(config.menus[firstCategory].subMenus)[0];
const firstDialogPath = generatePath(Routes.TUNE_DIALOG, {
tuneId,
@ -51,16 +51,18 @@ const Tune = ({ config, tune }: { config: ConfigType | null; tune: TuneState })
groupMenuDialogMatch,
]);
if (!(tune && config && (dialogMatch || groupMenuDialogMatch))) {
if (!(config && (dialogMatch || groupMenuDialogMatch))) {
return <Loader />;
}
return (
<>
<SideBar matchedPath={dialogMatch!} matchedGroupMenuDialogPath={groupMenuDialogMatch} />
<SideBar matchedPath={dialogMatch} matchedGroupMenuDialogPath={groupMenuDialogMatch} />
<Dialog
name={
groupMenuDialogMatch ? groupMenuDialogMatch.params.dialog! : dialogMatch?.params.dialog!
groupMenuDialogMatch
? groupMenuDialogMatch.params.dialog!
: dialogMatch?.params.dialog ?? ''
}
url={groupMenuDialogMatch ? groupMenuDialogMatch.pathname : dialogMatch?.pathname || ''}
/>

View File

@ -69,12 +69,12 @@ import {
const { Item, useForm } = Form;
enum MaxFiles {
TUNE_FILES = 1,
LOG_FILES = 5,
TOOTH_LOG_FILES = 5,
CUSTOM_INI_FILES = 1,
}
const MaxFiles = {
TUNE_FILES: 1,
LOG_FILES: 5,
TOOTH_LOG_FILES: 5,
CUSTOM_INI_FILES: 1,
} as const;
interface ValidationResult {
result: boolean;
@ -160,12 +160,13 @@ const UploadPage = () => {
const noop = () => {};
const goToNewTune = () =>
const goToNewTune = () => {
navigate(
generatePath(Routes.TUNE_ROOT, {
tuneId: newTuneId!,
}),
);
};
const publishTune = async (values: TunesRecord) => {
setIsLoading(true);
@ -193,10 +194,10 @@ const UploadPage = () => {
const compressedCustomIniFile = customIniFile
? bufferToFile(
await compress(customIniFile!),
await compress(customIniFile),
(customIniFile as UploadedFile).uid
? customIniFile!.name
: removeFilenameSuffix(customIniFile!.name),
? customIniFile.name
: removeFilenameSuffix(customIniFile.name),
)
: null;
@ -238,7 +239,7 @@ const UploadPage = () => {
year,
hp,
stockHp,
readme: readme?.trim(),
readme: readme.trim(),
tags,
visibility,
tuneFile: compressedTuneFile as unknown as string,
@ -333,10 +334,10 @@ const UploadPage = () => {
return true;
};
const uploadTune = async (options: UploadRequestOption) => {
const uploadTune = (options: UploadRequestOption) => {
upload(
options,
async (file) => {
(file) => {
setTuneFile(file);
},
async (file) => {
@ -377,10 +378,10 @@ const UploadPage = () => {
);
};
const uploadLogs = async (options: UploadRequestOption) => {
const uploadLogs = (options: UploadRequestOption) => {
upload(
options,
async (file) => {
(file) => {
setLogFiles((prev) => [...prev, file]);
},
async (file) => {
@ -415,10 +416,10 @@ const UploadPage = () => {
);
};
const uploadToothLogs = async (options: UploadRequestOption) => {
const uploadToothLogs = (options: UploadRequestOption) => {
upload(
options,
async (file) => {
(file) => {
setToothLogFiles((prev) => [...prev, file]);
},
async (file) => {
@ -437,10 +438,10 @@ const UploadPage = () => {
);
};
const uploadCustomIni = async (options: UploadRequestOption) => {
const uploadCustomIni = (options: UploadRequestOption) => {
upload(
options,
async (file) => {
(file) => {
setCustomIniFile(file);
},
async (file) => {
@ -471,19 +472,19 @@ const UploadPage = () => {
);
};
const removeTuneFile = async () => {
const removeTuneFile = () => {
setTuneFile(undefined);
};
const removeLogFile = async (file: UploadFile) => {
const removeLogFile = (file: UploadFile) => {
setLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeToothLogFile = async (file: UploadFile) => {
const removeToothLogFile = (file: UploadFile) => {
setToothLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeCustomIniFile = async (_file: UploadFile) => {
const removeCustomIniFile = (_file: UploadFile) => {
setCustomIniFile(undefined);
};
@ -502,7 +503,7 @@ const UploadPage = () => {
setExistingTune(oldTune);
form.setFieldsValue(oldTune);
setIsEditMode(true);
setReadme(oldTune.readme!);
setReadme(oldTune.readme);
if (oldTune.tuneFile) {
setTuneFile(await fetchFile(oldTune.id, oldTune.tuneFile));
@ -527,7 +528,7 @@ const UploadPage = () => {
}
const tempLogFiles: File[] = [];
oldTune.logFiles?.forEach(async (fileName: string) => {
oldTune.logFiles.forEach(async (fileName: string) => {
tempLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultLogFilesList((prev) => [
...prev,
@ -541,7 +542,7 @@ const UploadPage = () => {
setLogFiles(tempLogFiles);
const tempToothLogFiles: File[] = [];
oldTune.toothLogFiles?.forEach(async (fileName: string) => {
oldTune.toothLogFiles.forEach(async (fileName: string) => {
tempToothLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultToothLogFilesList((prev) => [
...prev,
@ -570,7 +571,7 @@ const UploadPage = () => {
[currentUser, form, navigateToNewTuneId],
);
const prepareData = useCallback(async () => {
const prepareData = useCallback(() => {
const currentTuneId = routeMatch?.params.tuneId;
if (currentTuneId) {
loadExistingTune(currentTuneId);
@ -582,13 +583,6 @@ const UploadPage = () => {
useEffect(() => {
refreshUser().then((user) => {
if (user === null) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
if (!user) {
restrictedPage();
navigate(Routes.LOGIN);
@ -663,9 +657,14 @@ const UploadPage = () => {
const OpenButton = () => (
<>
<Row>
<Input style={{ width: `calc(100% - ${shareSupported ? 65 : 35}px)` }} value={shareUrl!} />
<Input style={{ width: `calc(100% - ${shareSupported ? 65 : 35}px)` }} value={shareUrl} />
<Tooltip title="Copy URL">
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(shareUrl!)} />
<Button
icon={<CopyOutlined />}
onClick={() => {
copyToClipboard(shareUrl!);
}}
/>
</Tooltip>
{shareSupported && (
<Tooltip title="Share">
@ -858,7 +857,9 @@ const UploadPage = () => {
rows={10}
showCount
value={readme}
onChange={(e) => setReadme(e.target.value)}
onChange={(e) => {
setReadme(e.target.value);
}}
maxLength={3_000}
/>
),

View File

@ -26,7 +26,7 @@ const Profile = () => {
const loadData = async () => {
setIsTunesLoading(true);
try {
const { items, totalItems } = await getUserTunes(route?.params.userId!, page, pageSize);
const { items, totalItems } = await getUserTunes(route?.params.userId ?? '', page, pageSize);
setTotal(totalItems);
setAuthor(items[0]!.expand!.author);
setTunesDataSource(items);
@ -60,8 +60,11 @@ const Profile = () => {
<List.Item
actions={[
<Button
key="show"
icon={<ArrowRightOutlined />}
onClick={() => navigate(tunePath(tune.tuneId))}
onClick={() => {
navigate(tunePath(tune.tuneId));
}}
/>,
]}
className={tune.visibility}

View File

@ -12,9 +12,11 @@ const EmailVerification = () => {
useEffect(() => {
confirmEmailVerification(routeMatch!.params.token!)
.then(() => emailVerificationSuccess())
.then(() => {
emailVerificationSuccess();
})
.catch((error) => {
emailVerificationFailed(error);
emailVerificationFailed(error as Error);
});
navigate(Routes.HUB);

View File

@ -40,9 +40,7 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
const [googleUrl, setGoogleUrl] = useState<string | null>(null);
const [githubUrl, setGithubUrl] = useState<string | null>(null);
const [facebookUrl, setFacebookUrl] = useState<string | null>(null);
const [providersStatuses, setProvidersStatuses] = useState<{
[key: string]: boolean;
}>({
const [providersStatuses, setProvidersStatuses] = useState<Record<string, boolean>>({
google: false,
github: false,
facebook: false,
@ -51,7 +49,9 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
const { login, signUp, listAuthMethods } = useAuth();
const navigate = useNavigate();
const isAnythingLoading = isEmailLoading || isOAuthLoading;
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
const redirectAfterLogin = useCallback(() => {
navigate(Routes.HUB);
}, [navigate]);
const isOauthEnabled = Object.values(providersStatuses).includes(true);
const emailLogin = async ({ email, password }: { email: string; password: string }) => {
@ -76,7 +76,11 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
email,
password,
username,
}: { email: string; password: string; username: string }) => {
}: {
email: string;
password: string;
username: string;
}) => {
setIsEmailLoading(true);
try {
const user = await signUp(email, password, username);
@ -94,13 +98,14 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
}
};
const oauthMethods: {
[provider: string]: {
const oauthMethods: Record<
string,
{
label: string;
icon: ReactNode;
onClick: () => void;
};
} = {
}
> = {
google: {
label: 'Google',
icon: <GoogleOutlined />,

View File

@ -15,17 +15,16 @@ const OauthCallback = () => {
useEffect(() => {
const authProviders = JSON.parse(
window.localStorage.getItem('authProviders') || '',
) as unknown as AuthProviderInfo[];
) as AuthProviderInfo[];
oAuth(
routeMatch?.params.provider as OAuthProviders,
searchParams.get('code')!,
authProviders.find((provider) => provider.name === routeMatch?.params.provider)
?.codeVerifier!,
).then(() => {
logInSuccessful();
navigate(Routes.HUB, { replace: true });
});
?.codeVerifier ?? '',
);
logInSuccessful();
navigate(Routes.HUB, { replace: true });
}, [navigate, oAuth, routeMatch, searchParams]);
return <Loader />;

View File

@ -44,12 +44,13 @@ const Profile = () => {
const [isTunesLoading, setIsTunesLoading] = useState(false);
const [tunesDataSource, setTunesDataSource] = useState<TunesResponse[]>([]);
const goToEdit = (tuneId: string) =>
const goToEdit = (tuneId: string) => {
navigate(
generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId,
}),
);
};
const resendEmailVerification = async () => {
setIsSendingVerification(true);
@ -100,7 +101,7 @@ const Profile = () => {
}
refreshUser().then((user) => {
if (currentUser === null || user === null) {
if (user === null) {
restrictedPage();
navigate(Routes.LOGIN);
}
@ -180,10 +181,19 @@ const Profile = () => {
) : (
<EyeOutlined />
),
<Button icon={<EditOutlined />} onClick={() => goToEdit(tune.tuneId)} />,
<Button
key="edit"
icon={<EditOutlined />}
onClick={() => {
goToEdit(tune.tuneId);
}}
/>,
<Button
key="view"
icon={<ArrowRightOutlined />}
onClick={() => navigate(tunePath(tune.tuneId))}
onClick={() => {
navigate(tunePath(tune.tuneId));
}}
/>,
]}
>

View File

@ -19,6 +19,7 @@ const ResetPasswordConfirmation = () => {
const changePassword = async ({ password }: { password: string }) => {
setIsLoading(true);
try {
await confirmResetPassword(routeMatch!.params.token!, password);
passwordUpdateSuccess();

View File

@ -7,175 +7,200 @@ const baseOptions = {
placement: 'bottomRight' as const,
};
const error = (message: string, description: string) =>
const error = (message: string, description: string) => {
notification.warning({
message,
description,
...baseOptions,
});
};
const emailNotVerified = () =>
const emailNotVerified = () => {
notification.warning({
message: 'Check your email',
description: 'Your email address has to be verified before you can upload files!',
...baseOptions,
});
};
const signUpSuccessful = () =>
const signUpSuccessful = () => {
notification.success({
message: 'Sign Up successful',
description: 'Welcome on board!',
...baseOptions,
});
};
const signUpFailed = (err: Error) =>
const signUpFailed = (err: Error) => {
notification.error({
message: 'Failed to create an account',
description: err.message,
...baseOptions,
});
};
const logInSuccessful = () =>
const logInSuccessful = () => {
notification.success({
message: 'Log in successful',
...baseOptions,
});
};
const logInFailed = (err: Error) =>
const logInFailed = (err: Error) => {
notification.error({
message: 'Failed to log in',
description: err.message,
...baseOptions,
});
};
const restrictedPage = () =>
const restrictedPage = () => {
notification.warning({
message: 'Restricted page',
description: 'You have to be logged in to access this page!',
...baseOptions,
});
};
const logOutSuccessful = () =>
const logOutSuccessful = () => {
notification.success({
message: 'Log out successful',
...baseOptions,
});
};
const resetSuccessful = () =>
const resetSuccessful = () => {
notification.success({
message: 'Password reset initiated',
description: 'Check your email!',
...baseOptions,
});
};
const resetFailed = (err: Error) =>
const resetFailed = (err: Error) => {
notification.error({
message: 'Password reset failed',
description: err.message,
...baseOptions,
});
};
const sendingEmailVerificationFailed = (err: Error) =>
const sendingEmailVerificationFailed = (err: Error) => {
notification.success({
message: 'Sending verification email failed',
description: err.message,
...baseOptions,
});
};
const emailVerificationSent = () =>
const emailVerificationSent = () => {
notification.success({
message: 'Check your email',
description: 'Email verification sent!',
...baseOptions,
});
};
const emailVerificationFailed = (err: Error) =>
const emailVerificationFailed = (err: Error) => {
notification.error({
message: 'Email verification failed',
description: err.message,
...baseOptions,
});
};
const emailVerificationSuccess = () =>
const emailVerificationSuccess = () => {
notification.success({
message: 'Email verified',
description: 'Your email has been verified!',
...baseOptions,
});
};
const profileUpdateSuccess = () =>
const profileUpdateSuccess = () => {
notification.success({
message: 'Profile updated',
description: 'Your profile has been updated!',
...baseOptions,
});
};
const profileUpdateFailed = (err: Error) =>
const profileUpdateFailed = (err: Error) => {
notification.error({
message: 'Unable to update your profile',
description: err.message,
...baseOptions,
});
};
const passwordUpdateSuccess = () =>
const passwordUpdateSuccess = () => {
notification.success({
message: 'Password changed',
description: 'Your password has been changed!',
...baseOptions,
});
};
const passwordUpdateFailed = (err: Error) =>
const passwordUpdateFailed = (err: Error) => {
notification.error({
message: 'Unable to change your password',
description: err.message,
...baseOptions,
});
};
const databaseGenericError = (err: Error) =>
const databaseGenericError = (err: Error) => {
notification.error({
message: 'Database Error',
description: err.message,
...baseOptions,
});
};
const iniLoadingError = (err: Error) =>
const iniLoadingError = (err: Error) => {
notification.error({
message: 'INI not found',
description: err.message,
...baseOptions,
});
};
const tuneNotFound = () =>
const tuneNotFound = () => {
notification.warning({
message: 'Tune not found',
...baseOptions,
});
};
const tuneParsingError = () =>
const tuneParsingError = () => {
notification.error({
message: 'Tune file is not valid',
...baseOptions,
});
};
const copiedToClipboard = () =>
const copiedToClipboard = () => {
notification.success({
message: 'Copied to clipboard',
...baseOptions,
});
};
const signatureNotSupportedWarning = (message: string) =>
const signatureNotSupportedWarning = (message: string) => {
notification.warning({
message,
description: 'You need to upload custom INI file with your tune!',
...baseOptions,
});
};
const downloading = () =>
const downloading = () => {
notification.success({
message: 'Downloading...',
...baseOptions,
duration: 1,
});
};
export {
error,

View File

@ -1,14 +1,17 @@
import PocketBase, { AuthMethodsList, AuthProviderInfo, ClientResponseError } from 'pocketbase';
import { TypedPocketBase } from './@types/pocketbase-types';
import { fetchEnv } from './utils/env';
const API_URL = fetchEnv('VITE_POCKETBASE_API_URL');
const client = new PocketBase(API_URL);
const client = new PocketBase(API_URL) as TypedPocketBase;
const formatError = (error: ClientResponseError) => {
const { data, message } = error;
if (data.data) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const errors = Object.keys(data.data).map(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(key) => `${key.toUpperCase()}: ${data.data[key].message}`,
);
if (errors.length > 0) {

View File

@ -1,5 +1,4 @@
export enum Routes {
ROOT = '/',
HUB = '/',
TUNE_ROOT = '/t/:tuneId',

View File

@ -29,11 +29,16 @@ const toggleSidebar = createAction('ui/toggleSidebar');
const initialState: AppState = {
tune: {
constants: {},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
details: {} as any,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
tuneData: {} as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
logs: {} as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
toothLogs: {} as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
config: {} as any,
ui: {
sidebarCollapsed: false,

View File

@ -2,6 +2,4 @@ export interface ParserInterface {
parse(onProgress: (percent: number) => void): this;
}
export interface ParserConstructor {
new (buffer: ArrayBuffer): ParserInterface;
}
export type ParserConstructor = new (buffer: ArrayBuffer) => ParserInterface;

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export const isMac = `${window.navigator.platform}`.includes('Mac');
export const environment = import.meta.env.VITE_ENVIRONMENT || 'development';
export const isProduction = environment === 'production';
@ -10,5 +12,5 @@ export const fetchEnv = (envName: string): string => {
throw new Error(`Missing ENV: ${envName}`);
}
return envValue;
return envValue as string;
};

View File

@ -1,6 +1,6 @@
import locations from '../data/edge-locations.json';
type LocationsType = { [index: string]: string };
type LocationsType = Record<string, string>;
export type OnProgress = (percent: number, total: number, edgeLocation: string | null) => void;
@ -41,8 +41,8 @@ export const fetchWithProgress = async (
break;
}
array.set(value as Uint8Array, at);
at += (value as Uint8Array).length;
array.set(value, at);
at += value.length;
if (onProgress) {
onProgress(~~((at / length) * 100), length, edgeLocation);

View File

@ -7,5 +7,6 @@ enum Keys {
ESCAPE = 'Escape',
}
export const isToggleSidebar = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.key === Keys.SIDEBAR;
export const isEscape = (e: KeyEvent) => e.key === Keys.ESCAPE;
export const isToggleSidebar = (e: KeyEvent) =>
(e.metaKey || e.ctrlKey) && e.key === (Keys.SIDEBAR as string);
export const isEscape = (e: KeyEvent) => e.key === (Keys.ESCAPE as string);

View File

@ -34,7 +34,6 @@ class LogValidator implements ParserInterface {
private checkMLG() {
const fileFormat = new TextDecoder('utf8')
.decode(this.buffer.slice(0, this.mlgFormatLength))
// biome-ignore lint/suspicious/noControlCharactersInRegex: false positive
.replace(/\x00/gu, '');
if (fileFormat === 'MLVLG') {

View File

@ -86,9 +86,13 @@ class TriggerLogsParser implements ParserInterface {
}
private detectType(): void {
this.raw.split('\n').some((line, index) => {
this.raw.split('\n').forEach((line, index) => {
const trimmed = line.trim();
if (trimmed.startsWith(this.commentPrefix)) {
return;
}
// give up
if (index > 10) {
return true;
@ -102,7 +106,7 @@ class TriggerLogsParser implements ParserInterface {
return true;
}
if (parts.length >= 7) {
if (parts.length >= 6) {
this.isCompositeLogs = true;
return true;
@ -227,6 +231,16 @@ class TriggerLogsParser implements ParserInterface {
time: Number(parts[6]),
});
}
if (parts.length === 6) {
// PriLevel,SecLevel,Trigger,Sync,ToothTime,Time
this.resultComposite.push({
...base,
maxTime: 0,
toothTime: Number(parts[4]),
time: Number(parts[5]),
});
}
});
}
}

View File

@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { TuneWithDetails } from '@hyper-tuner/types';
class TuneParser {
@ -16,14 +20,14 @@ class TuneParser {
},
};
parse(buffer: ArrayBuffer): TuneParser {
parse(buffer: ArrayBuffer): this {
const raw = new TextDecoder().decode(buffer);
const xml = new DOMParser().parseFromString(raw, 'text/xml');
const xmlPages = xml.getElementsByTagName('page');
const bibliography = xml.getElementsByTagName('bibliography')[0]?.attributes as any;
const versionInfo = xml.getElementsByTagName('versionInfo')[0]?.attributes as any;
const bibliography = (xml.getElementsByTagName('bibliography')[0] as any)?.attributes;
const versionInfo = (xml.getElementsByTagName('versionInfo')[0] as any)?.attributes;
if (!(xmlPages && versionInfo)) {
if (!versionInfo) {
this.isTuneValid = false;
return this;

View File

@ -4,6 +4,7 @@ import {
Page as ConfigPageType,
SimpleConstant as SimpleConstantType,
TuneConstants as TuneConstantsType,
ConstantTypes,
} from '@hyper-tuner/types';
import * as Sentry from '@sentry/browser';
@ -35,8 +36,8 @@ export const prepareConstDeclarations = (
];
// we need array index instead of a display value
if (constant?.type === 'bits') {
val = (constant.values as string[]).indexOf(`${val}`);
if (constant?.type === ConstantTypes.BITS) {
val = constant.values.indexOf(`${val}`);
}
// escape string values
@ -82,6 +83,7 @@ export const evaluateExpression = (
try {
// TODO: strip eval from `command` etc
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return eval(`
'use strict';
const arrayValue = (number, layout) => number;

View File

@ -1,4 +1,4 @@
export const aspirationMapper: { [key: string]: string } = {
export const aspirationMapper: Record<string, string> = {
na: 'N/A',
turbocharged: 'Turbocharged',
supercharged: 'Supercharged',

View File

@ -0,0 +1,57 @@
import { Plugin as uPlotPlugin } from 'uplot';
import { ZoomPluginOptions } from './zoomPluginOptions';
const ARROW_UP = 'ArrowUp';
const ARROW_DOWN = 'ArrowDown';
const ARROW_LEFT = 'ArrowLeft';
const ARROW_RIGHT = 'ArrowRight';
function keyboardZoomPlugin(options: ZoomPluginOptions = {}): uPlotPlugin {
const { zoomFactor = 0.9, panFactor = 0.3 } = options;
return {
hooks: {
ready(u) {
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
return;
}
const cursor = u.cursor;
const { left, top } = cursor;
const xVal = u.posToVal(left || 0, 'x');
const yVal = u.posToVal(top || 0, 'y');
const xRange = (u.scales.x.max ?? 0) - (u.scales.x.min ?? 0);
const yRange = (u.scales.y.max ?? 0) - (u.scales.y.min ?? 0);
if (e.key === ARROW_UP || e.key === ARROW_DOWN) {
const zoomOut = e.key === ARROW_DOWN;
const newZoomFactor = zoomOut ? 1 / zoomFactor : zoomFactor;
const nxRange = xRange * newZoomFactor;
const nxMin = xVal - (xVal - (u.scales.x.min ?? 0)) * newZoomFactor;
const nyRange = yRange * newZoomFactor;
const nyMin = yVal - (yVal - (u.scales.y.min ?? 0)) * newZoomFactor;
u.batch(() => {
u.setScale('x', { min: nxMin, max: nxMin + nxRange });
u.setScale('y', { min: nyMin, max: nyMin + nyRange });
});
} else if (e.key === ARROW_LEFT || e.key === ARROW_RIGHT) {
const scrollDirection = e.key === ARROW_LEFT ? -1 : 1;
const scrollAmount = (xRange / 10) * scrollDirection * panFactor;
const nxMin = (u.scales.x.min ?? 0) + scrollAmount;
u.batch(() => {
u.setScale('x', { min: nxMin, max: nxMin + xRange });
});
}
});
},
},
};
}
export default keyboardZoomPlugin;

View File

@ -0,0 +1,133 @@
import uPlot, { Plugin as uPlotPlugin } from 'uplot';
import { ZoomPluginOptions } from './zoomPluginOptions';
const chartInstances: uPlot[] = [];
const mouseZoomPlugin = (options: ZoomPluginOptions = {}): uPlotPlugin => {
const { zoomFactor = 0.9 } = options;
let xMin: number;
let xMax: number;
let yMin: number;
let yMax: number;
let xRange: number;
let yRange: number;
let over: HTMLElement | null = null;
let rect: DOMRect | null = null;
function isCtrlPressed(e: MouseEvent): boolean {
return e.ctrlKey || e.metaKey;
}
function clamp(
nRange: number,
nMin: number,
nMax: number,
fRange: number,
fMin: number,
fMax: number,
): [number, number] {
let newNMin = nMin;
let newNMax = nMax;
if (nRange > fRange) {
newNMin = fMin;
newNMax = fMax;
} else if (nMin < fMin) {
newNMin = fMin;
newNMax = fMin + nRange;
} else if (nMax > fMax) {
newNMax = fMax;
newNMin = fMax - nRange;
}
return [newNMin, newNMax];
}
return {
hooks: {
ready(u) {
chartInstances.push(u); // Add the current chart instance to the list
xMin = u.scales.x.min ?? 0;
xMax = u.scales.x.max ?? 0;
yMin = u.scales.y.min ?? 0;
yMax = u.scales.y.max ?? 0;
xRange = xMax - xMin;
yRange = yMax - yMin;
over = u.over;
rect = over.getBoundingClientRect();
over.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
const left0 = e.clientX;
const scXMin0 = u.scales.x.min;
const scXMax0 = u.scales.x.max;
const xUnitsPerPx = u.valToPos(1, 'x') - u.valToPos(0, 'x');
function onMove(e: MouseEvent) {
e.preventDefault();
const left1 = e.clientX;
const dx = xUnitsPerPx * (left1 - left0);
u.setScale('x', {
min: (scXMin0 ?? 0) - dx,
max: (scXMax0 ?? 0) - dx,
});
}
document.addEventListener('mousemove', onMove);
}
});
over.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();
if (!isCtrlPressed(e)) {
return;
}
const cursor = u.cursor;
const { left, top } = cursor;
const leftPct = (left || 0) / (rect?.width || 1);
const btmPct = 1 - (top || 0) / (rect?.height || 1);
const xVal = u.posToVal(left || 0, 'x');
const yVal = u.posToVal(top || 0, 'y');
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const oyRange = (u.scales.y.max || 0) - (u.scales.y.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * zoomFactor : oxRange / zoomFactor;
let nxMin = xVal - leftPct * nxRange;
let nxMax = nxMin + nxRange;
[nxMin, nxMax] = clamp(nxRange, nxMin, nxMax, xRange, xMin, xMax);
const nyRange = e.deltaY < 0 ? oyRange * zoomFactor : oyRange / zoomFactor;
let nyMin = yVal - btmPct * nyRange;
let nyMax = nyMin + nyRange;
[nyMin, nyMax] = clamp(nyRange, nyMin, nyMax, yRange, yMin, yMax);
// Loop through all chart instances and apply the same zoom to each of them
chartInstances.forEach((chartInstance) => {
chartInstance.batch(() => {
chartInstance.setScale('x', {
min: nxMin,
max: nxMax,
});
chartInstance.setScale('y', {
min: nyMin,
max: nyMax,
});
});
});
});
},
},
};
};
export default mouseZoomPlugin;

View File

@ -1,138 +1,138 @@
import uPlot from 'uplot';
import uPlot, { Plugin as uPlotPlugin } from 'uplot';
interface Point {
type Point = {
x: number;
y: number;
d?: number;
dx: number;
dy: number;
}
};
const touchZoomPlugin = () => {
const init = (u: uPlot, _opts: any, _data: any) => {
const { over } = u;
let rect: DOMRect;
let oxRange: number;
let oyRange: number;
let xVal: number;
let yVal: number;
const fr: Point = { x: 0, y: 0, dx: 0, dy: 0 };
const to: Point = { x: 0, y: 0, dx: 0, dy: 0 };
const storePos = (t: Point, e: TouchEvent) => {
const ts = e.touches;
const t0 = ts[0];
const t0x = t0.clientX - rect.left;
const t0y = t0.clientY - rect.top;
if (ts.length === 1) {
t.x = t0x;
t.y = t0y;
t.d = t.dx = t.dy = 1;
} else {
const t1 = e.touches[1];
const t1x = t1.clientX - rect.left;
const t1y = t1.clientY - rect.top;
const xMin = Math.min(t0x, t1x);
const yMin = Math.min(t0y, t1y);
const xMax = Math.max(t0x, t1x);
const yMax = Math.max(t0y, t1y);
// mid points
t.y = (yMin + yMax) / 2;
t.x = (xMin + xMax) / 2;
t.dx = xMax - xMin;
t.dy = yMax - yMin;
// dist
t.d = Math.sqrt(t.dx * t.dx + t.dy * t.dy);
}
};
let rafPending = false;
const zoom = () => {
rafPending = false;
const left = to.x;
const top = to.y;
// non-uniform scaling
// let xFactor = fr.dx / to.dx;
// let yFactor = fr.dy / to.dy;
// uniform x/y scaling
const xFactor = fr.d! / to.d!;
const yFactor = fr.d! / to.d!;
const leftPct = left / rect.width;
const btmPct = 1 - top / rect.height;
const nxRange = oxRange * xFactor;
const nxMin = xVal - leftPct * nxRange;
const nxMax = nxMin + nxRange;
const nyRange = oyRange * yFactor;
const nyMin = yVal - btmPct * nyRange;
const nyMax = nyMin + nyRange;
u.batch(() => {
u.setScale('x', {
min: nxMin,
max: nxMax,
});
u.setScale('y', {
min: nyMin,
max: nyMax,
});
});
};
const touchmove = (e: TouchEvent) => {
storePos(to, e);
if (!rafPending) {
rafPending = true;
requestAnimationFrame(zoom);
}
};
over.addEventListener('touchstart', (e: TouchEvent) => {
if (e.touches.length > 1) {
// prevent default pinch zoom
e.preventDefault();
}
rect = over.getBoundingClientRect();
storePos(fr, e);
oxRange = u.scales.x.max! - u.scales.x.min!;
oyRange = u.scales.y.max! - u.scales.y.min!;
const left = fr.x;
const top = fr.y;
xVal = u.posToVal(left, 'x');
yVal = u.posToVal(top, 'y');
document.addEventListener('touchmove', touchmove, { passive: true });
});
over.addEventListener('touchend', (_e: TouchEvent) => {
document.removeEventListener('touchmove', touchmove);
});
};
const chartInstances: uPlot[] = [];
const touchZoomPlugin = (): uPlotPlugin => {
return {
hooks: {
init,
init(u, _opts, _data) {
const { over } = u;
let rect: DOMRect;
let oxRange: number;
let oyRange: number;
let xVal: number;
let yVal: number;
const fr: Point = { x: 0, y: 0, dx: 0, dy: 0 };
const to: Point = { x: 0, y: 0, dx: 0, dy: 0 };
const storePos = (t: Point, e: TouchEvent) => {
const ts = e.touches;
const t0 = ts[0];
const t0x = t0.clientX - rect.left;
const t0y = t0.clientY - rect.top;
if (ts.length === 1) {
t.x = t0x;
t.y = t0y;
t.d = t.dx = t.dy = 1;
} else {
const t1 = e.touches[1];
const t1x = t1.clientX - rect.left;
const t1y = t1.clientY - rect.top;
const xMin = Math.min(t0x, t1x);
const yMin = Math.min(t0y, t1y);
const xMax = Math.max(t0x, t1x);
const yMax = Math.max(t0y, t1y);
// mid points
t.y = (yMin + yMax) / 2;
t.x = (xMin + xMax) / 2;
t.dx = xMax - xMin;
t.dy = yMax - yMin;
// dist
t.d = Math.sqrt(t.dx * t.dx + t.dy * t.dy);
}
};
let rafPending = false;
const zoomCharts = () => {
rafPending = false;
const left = to.x;
const top = to.y;
const xFactor = fr.d! / to.d!;
const yFactor = fr.d! / to.d!;
const leftPct = left / rect.width;
const btmPct = 1 - top / rect.height;
const nxRange = oxRange * xFactor;
const nxMin = xVal - leftPct * nxRange;
const nxMax = nxMin + nxRange;
const nyRange = oyRange * yFactor;
const nyMin = yVal - btmPct * nyRange;
const nyMax = nyMin + nyRange;
// Loop through all chart instances and apply the same zoom to each of them
chartInstances.forEach((chartInstance) => {
chartInstance.batch(() => {
chartInstance.setScale('x', {
min: nxMin,
max: nxMax,
});
chartInstance.setScale('y', {
min: nyMin,
max: nyMax,
});
});
});
};
const touchmove = (e: TouchEvent) => {
storePos(to, e);
if (!rafPending) {
rafPending = true;
requestAnimationFrame(zoomCharts);
}
};
over.addEventListener('touchstart', (e: TouchEvent) => {
if (e.touches.length > 1) {
// prevent default pinch zoom
e.preventDefault();
}
rect = over.getBoundingClientRect();
storePos(fr, e);
oxRange = u.scales.x.max! - u.scales.x.min!;
oyRange = u.scales.y.max! - u.scales.y.min!;
const left = fr.x;
const top = fr.y;
xVal = u.posToVal(left, 'x');
yVal = u.posToVal(top, 'y');
document.addEventListener('touchmove', touchmove, { passive: true });
});
over.addEventListener('touchend', (_e: TouchEvent) => {
document.removeEventListener('touchmove', touchmove);
});
chartInstances.push(u); // Add the current chart instance to the list
},
},
};
};

View File

@ -0,0 +1,4 @@
export type ZoomPluginOptions = {
zoomFactor?: number;
panFactor?: number;
};

View File

@ -3,7 +3,7 @@ import { fetchEnv } from './env';
export const buildFullUrl = (parts = [] as string[]) =>
`${fetchEnv('VITE_WEB_URL')}/#${parts.join('/')}`;
export const buildRedirectUrl = (redirectPage: string, params: { [param: string]: string }) => {
export const buildRedirectUrl = (redirectPage: string, params: Record<string, string>) => {
const url = new URL(fetchEnv('VITE_WEB_URL'));
url.search = new URLSearchParams({
redirect: redirectPage,

View File

@ -4,6 +4,7 @@ import { decompress } from '../utils/compression';
import LogValidator from '../utils/logs/LogValidator';
import MslLogParser from '../utils/logs/MslLogParser';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
const ctx: Worker = self as any;
export interface WorkerOutput {