Load tune from URL (#369)

This commit is contained in:
Piotr Rogowski 2022-01-09 23:33:38 +01:00 committed by GitHub
parent 89ad4cbc47
commit 0229830b1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 462 additions and 4798 deletions

2
.env
View File

@ -1,4 +1,6 @@
NPM_GITHUB_TOKEN=
REACT_APP_WEB_URL=http://localhost:3000
REACT_APP_SENTRY_DSN=
REACT_APP_FIREBASE_APP_SENTRY_DSN=
REACT_APP_FIREBASE_API_KEY=
REACT_APP_FIREBASE_AUTH_DOMAIN=

61
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,61 @@
# Development guide
This guide will help you set up this project.
## Requirements
- [Node](https://nodejs.org/) 16.x.x (Node Version Manager: [nvm](https://github.com/nvm-sh/nvm))
- [Firebase](https://console.firebase.google.com/)
- Authentication
- Storage
- Firestore Database
- [Firebase CLI](https://firebase.google.com/docs/cli)
- [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) (`brew install --cask google-cloud-sdk`)
### Setup local environment variables
```bash
cp .env .env.local
```
### Authenticate to GitHub Packages
Project uses shared packages (`@speedy-tuner/...`).
They are hosted using `GitHub Packages`, to install them you need to [authenticate to GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages) first.
#### Personal access token
Generate GitHub [Personal access token](https://github.com/settings/tokens).
Private token can be assign to ENV when running `npm install` in the same shell:
```bash
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
```
### Install dependencies and run in development mode
```bash
# install packages
npm install
# run development server
npm start
```
## Firebase
### Storage
Authenticate:
```bash
gcloud auth login
```
Set up CORS:
```bash
gsutil cors set cors.json gs://<YOUR-BUCKET>
```

View File

@ -13,16 +13,6 @@ Share your [Speeduino](https://speeduino.com/) tune and logs.
![Screenshot](https://speedytuner.cloud/img/screen.png)
## Project main goals
- 🚀 always free and open source (FOSS)
- 💻 Cloud based web app with CDN
- 🔥 `60 FPS` animations and fast load times
- 👍 good user experience
- 💎 intuitive, modern and responsive UI
- 👶 easy for newcomers with tips, tools and simple diagnostics
- 📱 touch screen friendly
## ECU firmware
- Documentation: [wiki.speeduino.com](https://wiki.speeduino.com/)
@ -32,39 +22,12 @@ Share your [Speeduino](https://speeduino.com/) tune and logs.
There are many ways in which you can participate in the project and every bit of help is greatly appreciated.
- 👋 Say Hi and start a conversation over at [Discussions](https://github.com/karniv00l/speedy-tuner/discussions)
- 🐞 [Submit bugs and feature requests](https://github.com/karniv00l/speedy-tuner/issues)
- 👋 Say Hi and start a conversation over at [Discussions](https://github.com/speedy-tuner/speedy-tuner-cloud/discussions)
- 🐞 [Submit bugs and feature requests](https://github.com/speedy-tuner/speedy-tuner-cloud/issues)
- 🧪 Test on different platforms, hardware and Speeduino firmware
- 👓 Review source code
- ⌨️ Write tests and refactor code according to best practices
## Development
### Recommended dev environment
- [Node](https://nodejs.org/) 16.x.x
### Authenticate to GitHub Packages
Project uses shared packages (`@speedy-tuner/...`).
They are hosted using `GitHub Packages`, to install them you need to [authenticate to GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages) first.
Private token can be assing to ENV like so when running `npm install` in the same shell:
```bash
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
```
### Install and run
```bash
# install packages
npm install
# run development server
npm start
# open in browser
open http://localhost:3000
```
Please see [Developer guide](https://github.com/speedy-tuner/speedy-tuner-cloud/blob/master/DEVELOPMENT.md)

11
firebase/cors.json Normal file
View File

@ -0,0 +1,11 @@
[
{
"origin": [
"*"
],
"method": [
"GET"
],
"maxAgeSeconds": 3600
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import {
Switch,
Route,
Redirect,
useLocation,
matchPath,
} from 'react-router-dom';
import {
Layout,
@ -15,21 +16,25 @@ import {
Suspense,
useCallback,
useEffect,
useMemo,
} from 'react';
import {
AppState,
UIState,
Config as ConfigType,
} from '@speedy-tuner/types';
import useBrowserStorage from './hooks/useBrowserStorage';
import TopBar from './components/TopBar';
import StatusBar from './components/StatusBar';
import { Routes } from './routes';
import useStorage from './hooks/useStorage';
import { loadAll } from './utils/api';
import { loadTune } from './utils/api';
import store from './store';
import Log from './pages/Log';
import 'react-perfect-scrollbar/dist/css/styles.css';
import './App.less';
import {
AppState,
NavigationState,
UIState,
} from './types/state';
import useDb from './hooks/useDb';
import useServerStorage from './hooks/useServerStorage';
// TODO: fix this
// lazy loading this component causes a weird Curve canvas scaling
@ -47,17 +52,41 @@ const { Content } = Layout;
const mapStateToProps = (state: AppState) => ({
ui: state.ui,
status: state.status,
config: state.config,
navigation: state.navigation,
});
const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) => {
const margin = ui.sidebarCollapsed ? 80 : 250;
const { getTune } = useDb();
const { getFile } = useServerStorage();
const { storageSet } = useBrowserStorage();
// const [lastDialogPath, setLastDialogPath] = useState<string|null>();
const { storageGetSync } = useStorage();
const lastDialogPath = storageGetSync('lastDialog');
// const lastDialogPath = storageGetSync('lastDialog');
const { pathname } = useLocation();
const matchedTunePath = useMemo(() => matchPath(pathname, {
path: Routes.TUNE_ROOT,
}), [pathname]);
useEffect(() => {
loadAll();
const tuneId = (matchedTunePath?.params as any)?.tuneId;
if (tuneId) {
getTune(tuneId).then(async (tuneData) => {
const [tuneRaw, iniRaw] = await Promise.all([
await getFile(tuneData.tuneFile!),
await getFile(tuneData.customIniFile!),
]);
loadTune(tuneRaw, iniRaw);
});
storageSet('lastTuneId', tuneId);
store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
}
// storageGet('lastDialog')
// .then((path) => setLastDialogPath(path));
@ -95,42 +124,51 @@ const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
return (
<>
<Layout>
<TopBar />
<TopBar tuneId={navigation.tuneId} />
<Switch>
<Route path={Routes.ROOT} exact>
<Redirect to={lastDialogPath || Routes.TUNE} />
{/* <Route path={Routes.ROOT} exact>
<Redirect to={lastDialogPath || Routes.TUNE_ROOT} />
</Route> */}
<ContentFor>
<Result
status="info"
title="This page is under construction"
style={{ marginTop: 50 }}
/>
</ContentFor>
</Route>
<Route path={Routes.TUNE}>
<Route path={Routes.TUNE_TUNE}>
<ContentFor marginLeft={margin}>
<Tune />
</ContentFor>
</Route>
<Route path={Routes.LOG}>
<Route path={Routes.TUNE_LOG} exact>
<ContentFor marginLeft={margin}>
<Log />
</ContentFor>
</Route>
<Route path={Routes.DIAGNOSE}>
<Route path={Routes.TUNE_DIAGNOSE} exact>
<ContentFor marginLeft={margin}>
<Diagnose />
</ContentFor>
</Route>
<Route path={Routes.LOGIN}>
<Route path={Routes.LOGIN} exact>
<ContentFor>
<Login />
</ContentFor>
</Route>
<Route path={Routes.SIGN_UP}>
<Route path={Routes.SIGN_UP} exact>
<ContentFor>
<SignUp />
</ContentFor>
</Route>
<Route path={Routes.RESET_PASSWORD}>
<Route path={Routes.RESET_PASSWORD} exact>
<ContentFor>
<ResetPassword />
</ContentFor>
</Route>
<Route path={Routes.UPLOAD}>
<Route path={Routes.UPLOAD} exact>
<ContentFor>
<Upload />
</ContentFor>

View File

@ -17,8 +17,6 @@ import {
import {
Config as ConfigType,
Menus as MenusType,
AppState,
UIState,
Tune as TuneType,
} from '@speedy-tuner/types';
import store from '../store';
@ -26,6 +24,11 @@ import Icon from './SideBar/Icon';
import { evaluateExpression } from '../utils/tune/expression';
import { Routes } from '../routes';
import useConfig from '../hooks/useConfig';
import {
AppState,
NavigationState,
UIState,
} from '../types/state';
const { Sider } = Layout;
const { SubMenu } = Menu;
@ -42,6 +45,7 @@ const mapStateToProps = (state: AppState) => ({
config: state.config,
tune: state.tune,
ui: state.ui,
navigation: state.navigation,
});
const SKIP_MENUS = [
@ -62,11 +66,13 @@ const SideBar = ({
config,
tune,
ui,
navigation,
matchedPath,
}: {
config: ConfigType,
tune: TuneType,
ui: UIState,
navigation: NavigationState,
matchedPath: DialogMatchedPathType,
}) => {
const sidebarWidth = 250;
@ -79,10 +85,11 @@ const SideBar = ({
} as any;
const { isConfigReady } = useConfig(config);
const checkCondition = useCallback((condition: string) => evaluateExpression(condition, tune.constants, config), [tune.constants, config]);
const buildLink = useCallback((main: string, sub: string) => generatePath(Routes.DIALOG, {
const buildUrl = useCallback((main: string, sub: string) => generatePath(Routes.TUNE_DIALOG, {
tuneId: navigation.tuneId || 'not-ready',
category: main,
dialog: sub,
}), []);
}), [navigation.tuneId]);
const [menus, setMenus] = useState<any[]>([]);
const menusList = useCallback((types: MenusType) => (
@ -99,7 +106,7 @@ const SideBar = ({
>
{Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
if (subMenuName === 'std_separator') {
return <Menu.Divider key={buildLink(menuName, subMenuName)} />;
return <Menu.Divider key={buildUrl(menuName, subMenuName)} />;
}
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
@ -114,11 +121,11 @@ const SideBar = ({
}
return (<Menu.Item
key={buildLink(menuName, subMenuName)}
key={buildUrl(menuName, subMenuName)}
icon={<Icon name={subMenuName} />}
disabled={!enabled}
>
<Link to={buildLink(menuName, subMenuName)}>
<Link to={buildUrl(menuName, subMenuName)}>
{subMenu.title}
</Link>
</Menu.Item>);
@ -126,7 +133,7 @@ const SideBar = ({
</SubMenu>
);
})
), [buildLink, checkCondition]);
), [buildUrl, checkCondition]);
useEffect(() => {
if (Object.keys(tune.constants).length) {

View File

@ -3,7 +3,10 @@ import {
useLocation,
useHistory,
} from 'react-router';
import { Link } from 'react-router-dom';
import {
Link,
generatePath,
} from 'react-router-dom';
import {
Layout,
Space,
@ -60,12 +63,15 @@ const { Header } = Layout;
const { useBreakpoint } = Grid;
const { SubMenu } = Menu;
const TopBar = () => {
const TopBar = ({ tuneId }: { tuneId: string | null }) => {
const { sm, lg } = useBreakpoint();
const { pathname } = useLocation();
const { currentUser, logout } = useAuth();
const history = useHistory();
const matchedTabPath = useMemo(() => matchPath(pathname, { path: Routes.TAB }), [pathname]);
const buildTuneUrl = (route: string) => tuneId ? generatePath(route, { tuneId }) : null;
const matchedTabPath = useMemo(() => matchPath(pathname, {
path: Routes.TUNE_TAB,
}), [pathname]);
const logoutClick = useCallback(async () => {
try {
await logout();
@ -96,38 +102,42 @@ const TopBar = () => {
return () => document.removeEventListener('keydown', handleGlobalKeyboard);
});
const tabs = (
<Col span={10} md={10} sm={16} style={{ textAlign: 'center' }}>
<Radio.Group
key={pathname}
defaultValue={matchedTabPath?.url}
optionType="button"
buttonStyle="solid"
onChange={(e) => history.push(e.target.value)}
>
<Radio.Button value={buildTuneUrl(Routes.TUNE_TUNE)}>
<Space>
<ToolOutlined />
{sm && 'Tune'}
</Space>
</Radio.Button>
<Radio.Button value={buildTuneUrl(Routes.TUNE_LOG)}>
<Space>
<FundOutlined />
{sm && 'Log'}
</Space>
</Radio.Button>
<Radio.Button value={buildTuneUrl(Routes.TUNE_DIAGNOSE)}>
<Space>
<SettingOutlined />
{sm && 'Diagnose'}
</Space>
</Radio.Button>
</Radio.Group>
</Col>
);
return (
<Header className="app-top-bar">
<Row>
<Col span={0} md={6} sm={0} />
<Col span={10} md={10} sm={16} style={{ textAlign: 'center' }}>
<Radio.Group
key={pathname}
defaultValue={matchedTabPath?.url}
optionType="button"
buttonStyle="solid"
onChange={(e) => history.push(e.target.value)}
>
<Radio.Button value={Routes.TUNE}>
<Space>
<ToolOutlined />
{sm && 'Tune'}
</Space>
</Radio.Button>
<Radio.Button value={Routes.LOG}>
<Space>
<FundOutlined />
{sm && 'Log'}
</Space>
</Radio.Button>
<Radio.Button value={Routes.DIAGNOSE}>
<Space>
<SettingOutlined />
{sm && 'Diagnose'}
</Space>
</Radio.Button>
</Radio.Group>
</Col>
{tuneId ? tabs : <Col span={10} md={10} sm={16} />}
<Col span={12} md={8} sm={8} style={{ textAlign: 'right' }}>
<Space>
<Tooltip visible={false} title={
@ -140,7 +150,7 @@ const TopBar = () => {
<Button disabled icon={<SearchOutlined />} ref={searchInput} />
</Tooltip>
<Link to={Routes.UPLOAD}>
<Button icon={<CloudUploadOutlined />} type={matchedTabPath?.url === Routes.UPLOAD ? 'primary' : 'default' }>
<Button icon={<CloudUploadOutlined />} type={matchedTabPath?.url === Routes.UPLOAD ? 'primary' : 'default'}>
{lg && 'Upload'}
</Button>
</Link>

View File

@ -39,7 +39,7 @@ import {
} from '../../utils/tune/table';
import Map from './Dialog/Map';
import { evaluateExpression } from '../../utils/tune/expression';
import useStorage from '../../hooks/useStorage';
import useBrowserStorage from '../../hooks/useBrowserStorage';
import useConfig from '../../hooks/useConfig';
interface DialogsAndCurves {
@ -107,7 +107,7 @@ const Dialog = ({
url: string,
}) => {
const isDataReady = Object.keys(tune.constants).length && Object.keys(config.constants).length;
const { storageSet } = useStorage();
const { storageSet } = useBrowserStorage();
const { findConstantOnPage } = useConfig(config);
const [panelsComponents, setPanelsComponents] = useState<any[]>([]);
const containerRef = useRef<HTMLDivElement | null>(null);

View File

@ -18,6 +18,7 @@ import {
uploadBytes,
uploadBytesResumable,
deleteObject,
getBytes,
} from 'firebase/storage';
import {
getFirestore,
@ -62,6 +63,7 @@ export {
uploadBytes,
uploadBytesResumable,
deleteObject,
getBytes,
doc as fireStoreDoc,
collection as fireStoreCollection,
setDoc,

View File

@ -1,6 +1,6 @@
import { StorageInterface } from '../StorageInterface';
import { useMemo } from 'react';
class BrowserStorage implements StorageInterface {
class BrowserStorage {
private storage: Storage;
public constructor() {
@ -30,4 +30,15 @@ class BrowserStorage implements StorageInterface {
}
}
export default BrowserStorage;
const useBrowserStorage = () => {
const storage = useMemo(() => new BrowserStorage(), []);
return {
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),
};
};
export default useBrowserStorage;

48
src/hooks/useDb.ts Normal file
View File

@ -0,0 +1,48 @@
import { notification } from 'antd';
import * as Sentry from '@sentry/browser';
import {
fireStoreDoc,
getDoc,
setDoc,
db,
} from '../firebase';
import { TuneDbData } from '../types/dbData';
const genericError = (error: Error) => notification.error({ message: 'Database Error', description: error.message });
const useDb = () => {
const getData = async (tuneId: string, collection: string) => {
try {
const tune = (await getDoc(fireStoreDoc(db, collection, tuneId))).data() as TuneDbData;
return Promise.resolve(tune);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const updateData = async (tuneId: string, collection: string, data: TuneDbData) => {
try {
await setDoc(fireStoreDoc(db, collection, tuneId), data, { merge: true });
return Promise.resolve();
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
return {
getTune: (tuneId: string): Promise<TuneDbData> => getData(tuneId, 'tunes'),
updateData: (tuneId: string, data: TuneDbData): Promise<void> => updateData(tuneId, 'tunes', data),
};
};
export default useDb;

View File

@ -0,0 +1,31 @@
import { notification } from 'antd';
import * as Sentry from '@sentry/browser';
import {
storage,
storageRef,
getBytes,
} from '../firebase';
const genericError = (error: Error) => notification.error({ message: 'Database Error', description: error.message });
const useServerStorage = () => {
const getFile = async (path: string) => {
try {
const buffer = await getBytes(storageRef(storage, path));
return Promise.resolve(buffer);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
return {
getFile: (path: string): Promise<ArrayBuffer> => getFile(path),
};
};
export default useServerStorage;

View File

@ -1,15 +0,0 @@
import { useMemo } from 'react';
import Storage from '../utils/Storage';
const useStorage = () => {
const storage = useMemo(() => new Storage(), []);
return {
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),
};
};
export default useStorage;

View File

@ -19,6 +19,7 @@ import {
FileTextOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import * as Sentry from '@sentry/browser';
import pako from 'pako';
import useBreakpoint from 'antd/lib/grid/hooks/useBreakpoint';
import { connect } from 'react-redux';
@ -121,6 +122,7 @@ const Diagnose = ({ ui, config, loadedLogs }: { ui: UIState, config: Config, loa
setStep(2);
} catch (error) {
setFetchError(error as Error);
Sentry.captureException(error);
console.error(error);
}
};

View File

@ -7,30 +7,30 @@ import {
} from 'react-router-dom';
import { connect } from 'react-redux';
import { useMemo } from 'react';
import {
AppState,
UIState,
Config as ConfigType,
} from '@speedy-tuner/types';
import { Config as ConfigType } from '@speedy-tuner/types';
import Dialog from '../components/Tune/Dialog';
import SideBar, { DialogMatchedPathType } from '../components/SideBar';
import { Routes } from '../routes';
import useStorage from '../hooks/useStorage';
import useBrowserStorage from '../hooks/useBrowserStorage';
import useConfig from '../hooks/useConfig';
import {
AppState,
NavigationState,
} from '../types/state';
const mapStateToProps = (state: AppState) => ({
ui: state.ui,
navigation: state.navigation,
status: state.status,
config: state.config,
});
const Tune = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
const Tune = ({ navigation, config }: { navigation: NavigationState, config: ConfigType }) => {
const { pathname } = useLocation();
const { storageGetSync } = useStorage();
const { storageGetSync } = useBrowserStorage();
const lastDialogPath = storageGetSync('lastDialog');
const { isConfigReady } = useConfig(config);
const dialogMatchedPath: DialogMatchedPathType = useMemo(() => matchPath(pathname, {
path: Routes.DIALOG,
path: Routes.TUNE_DIALOG,
exact: true,
}) || { url: '', params: { category: '', dialog: '' } }, [pathname]);
@ -41,12 +41,16 @@ const Tune = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
const firstCategory = Object.keys(config.menus)[0];
const firstDialog = Object.keys(config.menus[firstCategory].subMenus)[0];
return generatePath(Routes.DIALOG, { category: firstCategory, dialog: firstDialog });
}, [config.menus, isConfigReady]);
return generatePath(Routes.TUNE_DIALOG, {
tuneId: navigation.tuneId || 'not-ready',
category: firstCategory,
dialog: firstDialog,
});
}, [config.menus, isConfigReady, navigation.tuneId]);
return (
<>
<Route path={Routes.TUNE} exact>
<Route path={Routes.TUNE_TUNE} exact>
{firstDialogPath && <Redirect to={lastDialogPath || firstDialogPath} />}
</Route>
<SideBar matchedPath={dialogMatchedPath} />

View File

@ -29,6 +29,7 @@ import {
ShareAltOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import * as Sentry from '@sentry/browser';
import { INI } from '@speedy-tuner/ini';
import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface';
@ -46,19 +47,17 @@ import {
import { useAuth } from '../contexts/AuthContext';
import { Routes } from '../routes';
import {
fireStoreDoc,
setDoc,
getDoc,
storage,
storageRef,
uploadBytesResumable,
deleteObject,
db,
} from '../firebase';
import useStorage from '../hooks/useStorage';
import useBrowserStorage from '../hooks/useBrowserStorage';
import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogParser from '../utils/logs/LogParser';
import { TuneDbData } from '../types/dbData';
import useDb from '../hooks/useDb';
enum MaxFiles {
TUNE_FILES = 1,
@ -67,36 +66,6 @@ enum MaxFiles {
CUSTOM_INI_FILES = 1,
}
interface TuneDataDetails {
readme?: string | null;
make?: string | null;
model?: string | null;
displacement?: string | null;
year?: number | null;
hp?: number | null;
stockHp?: number | null;
engineCode?: string | null;
cylinders?: number | null;
aspiration?: string | null;
fuel?: string | null;
injectors?: string | null;
coils?: string | null;
}
interface TuneDbData {
userUid?: string;
createdAt?: Date;
updatedAt?: Date;
isPublished?: boolean;
isListed?: boolean;
isPublic?: boolean;
tuneFile?: string | null;
logFiles?: string[];
toothLogFiles?: string[];
customIniFile?: string | null;
details?: TuneDataDetails;
}
type Path = string;
interface UploadedFile {
@ -150,7 +119,8 @@ const UploadPage = () => {
const hasNavigatorShare = navigator.share !== undefined;
const { currentUser, refreshToken } = useAuth();
const history = useHistory();
const { storageSet, storageGet, storageDelete } = useStorage();
const { storageSet, storageGet, storageDelete } = useBrowserStorage();
const { updateData, getTune } = useDb();
// details
const [readme, setReadme] = useState('# My Tune\n\ndescription');
@ -179,26 +149,6 @@ const UploadPage = () => {
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
const updateDbData = (tuneId: string, dbData: TuneDbData) => {
try {
return setDoc(fireStoreDoc(db, 'tunes', tuneId), dbData, { merge: true });
} catch (error) {
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getDbData = (tuneId: string) => {
try {
return getDoc(fireStoreDoc(db, 'tunes', tuneId));
} catch (error) {
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const removeFile = async (path: string) => {
try {
return await deleteObject(storageRef(storage, path));
@ -209,7 +159,7 @@ const UploadPage = () => {
const publish = async () => {
setIsLoading(true);
await updateDbData(newTuneId!, {
await updateData(newTuneId!, {
updatedAt: new Date(),
isPublished: true,
isPublic,
@ -272,6 +222,7 @@ const UploadPage = () => {
},
);
} catch (error) {
Sentry.captureException(error);
console.error('Upload error:', error);
notification.error({ message: 'Upload error', description: (error as Error).message });
onError!(error as Error);
@ -301,8 +252,8 @@ const UploadPage = () => {
});
const uploadTune = async (options: UploadRequestOption) => {
const found = await getDbData(newTuneId!);
if (!found.exists()) {
const found = await getTune(newTuneId!);
if (found) {
const tuneData: TuneDbData = {
userUid: currentUser!.uid,
createdAt: new Date(),
@ -316,15 +267,15 @@ const UploadPage = () => {
customIniFile: null,
details: {},
};
await updateDbData(newTuneId!, tuneData);
await updateData(newTuneId!, tuneData);
}
setShareUrl(`https://speedytuner.cloud/#/t/${newTuneId}`);
setShareUrl(`${process.env.REACT_APP_WEB_URL}/#/t/${newTuneId}`);
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
upload(path, options, () => {
updateDbData(newTuneId!, { tuneFile: path });
updateData(newTuneId!, { tuneFile: path });
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -353,7 +304,7 @@ const UploadPage = () => {
tune[uuid] = path;
const newValues = { ...logFiles, ...tune };
upload(path, options, () => {
updateDbData(newTuneId!, { logFiles: Object.values(newValues) });
updateData(newTuneId!, { logFiles: Object.values(newValues) });
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -394,7 +345,7 @@ const UploadPage = () => {
tune[(options.file as UploadFile).uid] = path;
const newValues = { ...toothLogFiles, ...tune };
upload(path, options, () => {
updateDbData(newTuneId!, { toothLogFiles: Object.values(newValues) });
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -420,7 +371,7 @@ const UploadPage = () => {
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
upload(path, options, () => {
updateDbData(newTuneId!, { customIniFile: path });
updateData(newTuneId!, { customIniFile: path });
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -428,6 +379,7 @@ const UploadPage = () => {
}
// TODO: change to common interface, add some validation method
// Create INI parser
const parser = new INI((new TextDecoder()).decode(await file.arrayBuffer()));
const valid = parser.parse().megaTune.signature.length > 0;
@ -447,7 +399,7 @@ const UploadPage = () => {
removeFile(tuneFile[file.uid]);
}
setTuneFile(null);
updateDbData(newTuneId!, { tuneFile: null });
updateData(newTuneId!, { tuneFile: null });
};
const removeLogFile = async (file: UploadFile) => {
@ -458,7 +410,7 @@ const UploadPage = () => {
const newValues = { ...logFiles };
delete newValues[uid];
setLogFiles(newValues);
updateDbData(newTuneId!, { logFiles: Object.values(newValues) });
updateData(newTuneId!, { logFiles: Object.values(newValues) });
};
const removeToothLogFile = async (file: UploadFile) => {
@ -469,7 +421,7 @@ const UploadPage = () => {
const newValues = { ...toothLogFiles };
delete newValues[uid];
setToothLogFiles(newValues);
updateDbData(newTuneId!, { toothLogFiles: Object.values(newValues) });
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
};
const removeCustomIniFile = async (file: UploadFile) => {
@ -477,7 +429,7 @@ const UploadPage = () => {
removeFile(customIniFile![file.uid]);
}
setCustomIniFile(null);
updateDbData(newTuneId!, { customIniFile: null });
updateData(newTuneId!, { customIniFile: null });
};
const prepareData = useCallback(async () => {
@ -498,6 +450,7 @@ const UploadPage = () => {
}
setIsUserAuthorized(true);
} catch (error) {
Sentry.captureException(error);
storageDelete(newTuneIdKey);
console.error(error);
genericError(error as Error);
@ -524,7 +477,7 @@ const UploadPage = () => {
const shareSection = (
<>
<Divider>Publish & Share</Divider>
<Row>
{isPublished && <Row>
<Input
style={{ width: `calc(100% - ${hasNavigatorShare ? 65 : 35}px)` }}
value={shareUrl!}
@ -540,16 +493,24 @@ const UploadPage = () => {
/>
</Tooltip>
)}
</Row>
</Row>}
<Row style={{ marginTop: 10 }}>
<Button
{!isPublished ? <Button
type="primary"
block
disabled={isPublished || isLoading}
disabled={isLoading}
onClick={publish}
>
{isPublished && !isLoading ? 'Published' : 'Publish'}
</Button>
Publish
</Button> : <Button
type="primary"
block
onClick={() => {
window.location.href = shareUrl as string;
}}
>
Open
</Button>}
</Row>
</>
);

View File

@ -39,30 +39,31 @@ const Login = () => {
const { login, googleAuth, githubAuth } = useAuth();
const history = useHistory();
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading;
const redirectAfterLogin = useCallback(() => history.push(Routes.UPLOAD), [history]);
const googleLogin = useCallback(async () => {
setIsGoogleLoading(true);
try {
await googleAuth();
logInSuccessful();
history.push(Routes.ROOT);
redirectAfterLogin();
} catch (error) {
logInFailed(error as Error);
setIsGoogleLoading(false);
}
}, [googleAuth, history]);
}, [googleAuth, redirectAfterLogin]);
const githubLogin = useCallback(async () => {
setIsGithubLoading(true);
try {
await githubAuth();
logInSuccessful();
history.push(Routes.ROOT);
redirectAfterLogin();
} catch (error) {
logInFailed(error as Error);
setIsGithubLoading(false);
}
}, [githubAuth, history]);
}, [githubAuth, redirectAfterLogin]);
const emailLogin = async ({ email, password }: { form: any, email: string, password: string }) => {
setIsEmailLoading(true);
@ -74,7 +75,7 @@ const Login = () => {
emailNotVerified();
}
history.push(Routes.ROOT);
redirectAfterLogin();
} catch (error) {
form.resetFields();
console.warn(error);

View File

@ -1,11 +1,14 @@
// eslint-disable-next-line import/prefer-default-export
export enum Routes {
ROOT = '/',
TAB = '/:tab',
TUNE = '/tune',
DIALOG = '/tune/:category/:dialog',
LOG = '/log',
DIAGNOSE = '/diagnose',
TUNE_ROOT = '/t/:tuneId',
TUNE_TAB = '/t/:tuneId/:tab',
TUNE_TUNE = '/t/:tuneId/tune',
TUNE_DIALOG = '/t/:tuneId/tune/:category/:dialog',
TUNE_LOG = '/t/:tuneId/log',
TUNE_DIAGNOSE = '/t/:tuneId/diagnose',
LOGIN = '/auth/login',
LOGOUT = '/auth/logout',
SIGN_UP = '/auth/sign-up',

View File

@ -5,15 +5,24 @@ import {
createAction,
createReducer,
} from '@reduxjs/toolkit';
import * as Types from '@speedy-tuner/types';
import {
AppState,
ConfigState,
LogsState,
TuneState,
UpdateTunePayload,
} from './types/state';
// tune and config
const updateTune = createAction<Types.UpdateTunePayload>('tune/update');
const loadTune = createAction<Types.TuneState>('tune/load');
const loadConfig = createAction<Types.ConfigState>('config/load');
const updateTune = createAction<UpdateTunePayload>('tune/update');
const loadTune = createAction<TuneState>('tune/load');
const loadConfig = createAction<ConfigState>('config/load');
// navigation
const setTuneId = createAction<string>('navigation/tuneId');
// logs
const loadLogs = createAction<Types.LogsState>('logs/load');
const loadLogs = createAction<LogsState>('logs/load');
// status bar
const setStatus = createAction<string>('status');
@ -22,7 +31,7 @@ const setStatus = createAction<string>('status');
const setSidebarCollapsed = createAction<boolean>('ui/sidebarCollapsed');
const toggleSidebar = createAction('ui/toggleSidebar');
const initialState: Types.AppState = {
const initialState: AppState = {
tune: {
constants: {},
},
@ -34,30 +43,36 @@ const initialState: Types.AppState = {
status: {
text: null,
},
navigation: {
tuneId: null,
},
};
const rootReducer = createReducer(initialState, (builder) => {
builder
.addCase(loadConfig, (state: Types.AppState, action) => {
.addCase(loadConfig, (state: AppState, action) => {
state.config = action.payload;
})
.addCase(loadTune, (state: Types.AppState, action) => {
.addCase(loadTune, (state: AppState, action) => {
state.tune = action.payload;
})
.addCase(loadLogs, (state: Types.AppState, action) => {
.addCase(loadLogs, (state: AppState, action) => {
state.logs = action.payload;
})
.addCase(updateTune, (state: Types.AppState, action) => {
.addCase(updateTune, (state: AppState, action) => {
state.tune.constants[action.payload.name].value = action.payload.value;
})
.addCase(setSidebarCollapsed, (state: Types.AppState, action) => {
.addCase(setSidebarCollapsed, (state: AppState, action) => {
state.ui.sidebarCollapsed = action.payload;
})
.addCase(toggleSidebar, (state: Types.AppState) => {
.addCase(toggleSidebar, (state: AppState) => {
state.ui.sidebarCollapsed = !state.ui.sidebarCollapsed;
})
.addCase(setStatus, (state: Types.AppState, action) => {
.addCase(setStatus, (state: AppState, action) => {
state.status.text = action.payload;
})
.addCase(setTuneId, (state: AppState, action) => {
state.navigation.tuneId = action.payload;
});
});

29
src/types/dbData.ts Normal file
View File

@ -0,0 +1,29 @@
export interface TuneDataDetails {
readme?: string | null;
make?: string | null;
model?: string | null;
displacement?: string | null;
year?: number | null;
hp?: number | null;
stockHp?: number | null;
engineCode?: string | null;
cylinders?: number | null;
aspiration?: string | null;
fuel?: string | null;
injectors?: string | null;
coils?: string | null;
}
export interface TuneDbData {
userUid?: string;
createdAt?: Date;
updatedAt?: Date;
isPublished?: boolean;
isListed?: boolean;
isPublic?: boolean;
tuneFile?: string | null;
logFiles?: string[];
toothLogFiles?: string[];
customIniFile?: string | null;
details?: TuneDataDetails;
}

37
src/types/state.ts Normal file
View File

@ -0,0 +1,37 @@
import {
Config,
Logs,
Tune,
} from '@speedy-tuner/types';
export interface ConfigState extends Config {}
export interface TuneState extends Tune {}
export interface LogsState extends Logs {}
export interface UIState {
sidebarCollapsed: boolean;
}
export interface StatusState {
text: string | null;
}
export interface NavigationState {
tuneId: string | null;
}
export interface AppState {
tune: TuneState;
config: ConfigState;
logs: LogsState,
ui: UIState;
status: StatusState;
navigation: NavigationState;
}
export interface UpdateTunePayload {
name: string;
value: string | number;
}

View File

@ -1,28 +0,0 @@
import BrowserStorage from './storage/BrowserStorage';
import { StorageInterface } from './StorageInterface';
class Storage {
private storage: StorageInterface;
public constructor() {
this.storage = new BrowserStorage();
}
public get(key: string) {
return this.storage.get(key);
}
public getSync(key: string) {
return this.storage.getSync(key);
}
public set(key: string, value: string) {
return this.storage.set(key, value);
}
public delete(key: string) {
return this.storage.delete(key);
}
}
export default Storage;

View File

@ -1,7 +0,0 @@
export interface StorageInterface {
get(key: string): Promise<string | null>;
getSync(key: string): string | null;
set(key: string, value: string): Promise<void>;
delete(key: string): void;
isAvailable(): Promise<boolean>;
}

View File

@ -1,4 +1,5 @@
import { Config as ConfigType } from '@speedy-tuner/types';
import { INI } from '@speedy-tuner/ini';
import pako from 'pako';
import store from '../store';
import stdDialogs from '../data/standardDialogs';
import help from '../data/help';
@ -9,22 +10,19 @@ import {
} from './http';
import TuneParser from './tune/TuneParser';
export const loadAll = async () => {
export const loadTune = async (tuneRaw: ArrayBuffer, iniRaw: ArrayBuffer) => {
const started = new Date();
// const version = 202012;
const version = 202103;
const json: ConfigType = await fetch(`./tunes/${version}.json`)
.then((response) => response.json());
const tuneRaw = await fetch(`./tunes/${version}.msq`);
const tuneParser = new TuneParser().parse(await tuneRaw.arrayBuffer());
const tuneParser = new TuneParser()
.parse(pako.inflate(new Uint8Array(tuneRaw)));
if (!tuneParser.isValid()) {
// TODO: capture exception
console.error('Invalid tune');
}
const config = json;
const buff = pako.inflate(new Uint8Array(iniRaw));
const config = new INI((new TextDecoder()).decode(buff)).parse();
// override / merge standard dialogs, constants and help
config.dialogs = {