Migrate to Appwrite (#657)

This commit is contained in:
Piotr Rogowski 2022-07-17 20:55:10 +02:00 committed by GitHub
parent 66c2bbb869
commit 9275c01d53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 3047 additions and 3515 deletions

16
.env
View File

@ -2,12 +2,12 @@ NPM_GITHUB_TOKEN=
VITE_ENVIRONMENT=development VITE_ENVIRONMENT=development
VITE_WEB_URL=http://localhost:3000 VITE_WEB_URL=http://localhost:3000
VITE_SENTRY_DSN= VITE_SENTRY_DSN=
VITE_FIREBASE_APP_SENTRY_DSN=
VITE_FIREBASE_API_KEY= # TODO: remove this later
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
VITE_FIREBASE_STORAGE_BUCKET=
VITE_FIREBASE_MESSAGING_SENDER_ID=
VITE_FIREBASE_APP_ID=
VITE_FIREBASE_MEASUREMENT_ID=
VITE_CDN_URL= VITE_CDN_URL=
VITE_APPWRITE_ENDPOINT=
VITE_APPWRITE_PROJECT_ID=
VITE_APPWRITE_DATABASE_ID=
VITE_APPWRITE_COLLECTION_ID_PUBLIC_TUNES=
VITE_APPWRITE_COLLECTION_ID_USERS_BUCKETS=

View File

@ -1,8 +1,3 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2 version: 2
registries: registries:
npm-github: npm-github:
@ -10,8 +5,8 @@ registries:
url: https://npm.pkg.github.com url: https://npm.pkg.github.com
token: ${{ secrets.NPM_GITHUB_PAT }} token: ${{ secrets.NPM_GITHUB_PAT }}
updates: updates:
- package-ecosystem: "npm" # See documentation for possible values - package-ecosystem: "npm"
directory: "/" # Location of package manifests directory: "/"
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 20 open-pull-requests-limit: 20

14
.gitignore vendored
View File

@ -12,7 +12,6 @@ node_modules
/build /build
# misc # misc
.DS_Store
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
@ -24,8 +23,17 @@ yarn-error.log*
.eslintcache .eslintcache
# custom ts builds # Editor directories and files
/src/**/*.js # .vscode/*
# !.vscode/extensions.json
# !.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# rollup-plugin-visualizer generated files # rollup-plugin-visualizer generated files
stats.html stats.html

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

View File

@ -1,7 +1,9 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [ "cSpell.words": [
"Appwrite",
"kbar", "kbar",
"prefs",
"vite", "vite",
"vitejs" "vitejs"
] ]

View File

@ -4,13 +4,7 @@ This guide will help you set up this project.
## Requirements ## Requirements
- [Node](https://nodejs.org/) 16.x.x (Node Version Manager: [nvm](https://github.com/nvm-sh/nvm)) - 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 ### Setup local environment variables
@ -34,6 +28,12 @@ Private token can be assign to ENV when running `npm install` in the same shell:
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
``` ```
### Setup correct Node.js version
```bash
nvm use
```
### Install dependencies and run in development mode ### Install dependencies and run in development mode
```bash ```bash
@ -43,19 +43,3 @@ npm install
# run development server # run development server
npm start npm start
``` ```
## Firebase
### Storage
Authenticate:
```bash
gcloud auth login
```
Set up CORS:
```bash
gsutil cors set firebase/cors.json gs://<YOUR-BUCKET>
```

View File

@ -3,10 +3,9 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/icons/icon.ico" /> <link rel="icon" href="/icons/icon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#222629" />
<link rel="apple-touch-icon" href="/icons/icon.png" /> <link rel="apple-touch-icon" href="/icons/icon.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://apis.google.com" crossorigin> <link rel="preconnect" href="https://apis.google.com" crossorigin>
<meta property="og:title" content="HyperTuner Cloud"> <meta property="og:title" content="HyperTuner Cloud">
<meta name="twitter:image:alt" content="HyperTuner Cloud"> <meta name="twitter:image:alt" content="HyperTuner Cloud">
@ -19,7 +18,7 @@
<meta name="description" content="HyperTuner - Share your tunes and logs" /> <meta name="description" content="HyperTuner - Share your tunes and logs" />
<title>HyperTuner Cloud</title> <title>HyperTuner Cloud</title>
</head> </head>
<body style="background-color: #222629;"> <body style="background-color: #191C1E">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!-- Vite entrypoint --> <!-- Vite entrypoint -->

3614
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,52 +15,55 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"serve": "vite preview", "serve": "vite preview",
"lint": "tsc && eslint --max-warnings=0 src", "lint": "tsc && eslint --max-warnings=0 src",
"lint:fix": "eslint --fix src" "lint:fix": "eslint --fix src",
"stats:bundle": "npm run build && open stats.html"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.7.2", "@hyper-tuner/ini": "^0.3.1",
"@sentry/react": "^6.18.0", "@hyper-tuner/types": "^0.3.3",
"@sentry/tracing": "^6.18.0", "@reduxjs/toolkit": "^1.8.3",
"@hyper-tuner/ini": "^0.3.0", "@sentry/react": "^7.7.0",
"@hyper-tuner/types": "^0.3.0", "@sentry/tracing": "^7.7.0",
"antd": "^4.18.8", "antd": "^4.21.6",
"firebase": "^9.6.7", "appwrite": "^9.0.1",
"kbar": "^0.1.0-beta.34", "kbar": "^0.1.0-beta.36",
"lodash.debounce": "^4.0.8",
"mlg-converter": "^0.5.1", "mlg-converter": "^0.5.1",
"nanoid": "^3.3.1", "nanoid": "^4.0.0",
"pako": "^2.0.4", "pako": "^2.0.4",
"react": "^18.1.0", "react": "^18.2.0",
"react-dom": "^18.1.0", "react-dom": "^18.2.0",
"react-markdown": "^8.0.0", "react-markdown": "^8.0.3",
"react-perfect-scrollbar": "^1.5.8", "react-perfect-scrollbar": "^1.5.8",
"react-redux": "^8.0.1", "react-redux": "^8.0.2",
"react-router-dom": "^6.2.1", "react-router-dom": "^6.3.0",
"uplot": "^1.6.19", "uplot": "^1.6.22",
"uplot-react": "^1.1.1", "uplot-react": "^1.1.1",
"vite": "^2.8.4" "vite": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@hyper-tuner/eslint-config": "^0.1.5", "@hyper-tuner/eslint-config": "^0.1.6",
"@types/node": "^17.0.19", "@types/lodash.debounce": "^4.0.7",
"@types/pako": "^1.0.3", "@types/node": "^18.0.5",
"@types/react": "^18.0.3", "@types/pako": "^2.0.0",
"@types/react-dom": "^18.0.3", "@types/react": "^18.0.15",
"@types/react-redux": "^7.1.22", "@types/react-dom": "^18.0.6",
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.12.1", "@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.21.0", "@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-react": "^1.2.0", "@vitejs/plugin-react": "^2.0.0",
"eslint": "^8.14.0", "eslint": "^8.20.0",
"eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-modules-newline": "^0.0.6", "eslint-plugin-modules-newline": "^0.0.6",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-react-hooks": "^4.6.0",
"less": "^4.1.2", "less": "^4.1.3",
"prettier": "^2.5.1", "prettier": "^2.7.1",
"rollup-plugin-visualizer": "^5.6.0", "rollup-plugin-visualizer": "^5.7.1",
"typescript": "^4.5.5" "typescript": "^4.7.4"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -17,14 +17,14 @@
"screenshots" : [ "screenshots" : [
{ {
"src": "/img/screen1.png", "src": "/img/screen1.png",
"sizes": "1920x1109", "sizes": "1920x1194",
"type": "image/png", "type": "image/png",
"platform": "wide", "platform": "wide",
"label": "VE Table with command palette" "label": "VE Table with command palette"
}, },
{ {
"src": "/img/screen2.png", "src": "/img/screen2.png",
"sizes": "1920x1111", "sizes": "1920x1194",
"type": "image/png", "type": "image/png",
"platform": "wide", "platform": "wide",
"label": "Log viewer" "label": "Log viewer"
@ -32,6 +32,6 @@
], ],
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"theme_color": "#222629", "theme_color": "#191C1E",
"background_color": "#222629" "background_color": "#191C1E"
} }

View File

@ -2,6 +2,7 @@ import {
Routes as ReactRoutes, Routes as ReactRoutes,
Route, Route,
useMatch, useMatch,
useNavigate,
} from 'react-router-dom'; } from 'react-router-dom';
import { import {
Layout, Layout,
@ -14,6 +15,7 @@ import {
Suspense, Suspense,
useCallback, useCallback,
useEffect, useEffect,
useState,
} from 'react'; } from 'react';
import TopBar from './components/TopBar'; import TopBar from './components/TopBar';
import StatusBar from './components/StatusBar'; import StatusBar from './components/StatusBar';
@ -25,6 +27,7 @@ import Loader from './components/Loader';
import { import {
AppState, AppState,
NavigationState, NavigationState,
TuneDataState,
UIState, UIState,
} from './types/state'; } from './types/state';
import useDb from './hooks/useDb'; import useDb from './hooks/useDb';
@ -32,7 +35,7 @@ import Info from './pages/Info';
import Hub from './pages/Hub'; import Hub from './pages/Hub';
import 'react-perfect-scrollbar/dist/css/styles.css'; import 'react-perfect-scrollbar/dist/css/styles.css';
import './App.less'; import './css/App.less';
// TODO: fix this // TODO: fix this
// lazy loading this component causes a weird Curve canvas scaling // lazy loading this component causes a weird Curve canvas scaling
@ -40,11 +43,14 @@ import './App.less';
const Tune = lazy(() => import('./pages/Tune')); const Tune = lazy(() => import('./pages/Tune'));
const Diagnose = lazy(() => import('./pages/Diagnose')); const Diagnose = lazy(() => import('./pages/Diagnose'));
const Upload = lazy(() => import('./pages/Upload'));
const Login = lazy(() => import('./pages/auth/Login')); const Login = lazy(() => import('./pages/auth/Login'));
const Profile = lazy(() => import('./pages/auth/Profile')); const Profile = lazy(() => import('./pages/auth/Profile'));
const SignUp = lazy(() => import('./pages/auth/SignUp')); const SignUp = lazy(() => import('./pages/auth/SignUp'));
const ResetPassword = lazy(() => import('./pages/auth/ResetPassword')); const ResetPassword = lazy(() => import('./pages/auth/ResetPassword'));
const Upload = lazy(() => import('./pages/Upload')); const MagicLinkConfirmation = lazy(() => import('./pages/auth/MagicLinkConfirmation'));
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
const ResetPasswordConfirmation = lazy(() => import('./pages/auth/ResetPasswordConfirmation'));
const { Content } = Layout; const { Content } = Layout;
@ -52,11 +58,32 @@ const mapStateToProps = (state: AppState) => ({
ui: state.ui, ui: state.ui,
status: state.status, status: state.status,
navigation: state.navigation, navigation: state.navigation,
tuneData: state.tuneData,
}); });
const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) => { const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: NavigationState, tuneData: TuneDataState }) => {
const margin = ui.sidebarCollapsed ? 80 : 250; const margin = ui.sidebarCollapsed ? 80 : 250;
const { getTune } = useDb(); const { getTune } = useDb();
const searchParams = new URLSearchParams(window.location.search);
const redirectPage = searchParams.get('redirectPage');
const [isLoading, setIsLoading] = useState(false);
const { getBucketId } = useDb();
const navigate = useNavigate();
// TODO: refactor this
switch (redirectPage) {
case Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION:
window.location.href = `/#${Routes.MAGIC_LINK_CONFIRMATION}?${searchParams.toString()}`;
break;
case Routes.REDIRECT_PAGE_EMAIL_VERIFICATION:
window.location.href = `/#${Routes.EMAIL_VERIFICATION}?${searchParams.toString()}`;
break;
case Routes.REDIRECT_PAGE_RESET_PASSWORD:
window.location.href = `/#${Routes.RESET_PASSWORD_CONFIRMATION}?${searchParams.toString()}`;
break;
default:
break;
}
// const [lastDialogPath, setLastDialogPath] = useState<string|null>(); // const [lastDialogPath, setLastDialogPath] = useState<string|null>();
// const lastDialogPath = storageGetSync('lastDialog'); // const lastDialogPath = storageGetSync('lastDialog');
@ -66,9 +93,25 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
useEffect(() => { useEffect(() => {
if (tuneId) { if (tuneId) {
getTune(tuneId).then(async (tuneData) => { // clear out last state
loadTune(tuneData); if (tuneData && tuneId !== tuneData.tuneId) {
store.dispatch({ type: 'tuneData/load', payload: tuneData }); setIsLoading(true);
loadTune(null, '');
store.dispatch({ type: 'tuneData/load', payload: null });
setIsLoading(false);
}
getTune(tuneId).then(async (tune) => {
if (!tune) {
console.warn('Tune not found');
navigate(Routes.HUB);
return;
}
getBucketId(tune.userId).then((bucketId) => {
loadTune(tune!, bucketId);
});
store.dispatch({ type: 'tuneData/load', payload: tune });
}); });
store.dispatch({ type: 'navigation/tuneId', payload: tuneId }); store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
@ -92,15 +135,17 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
<Layout style={{ marginLeft }}> <Layout style={{ marginLeft }}>
<Layout className="app-content"> <Layout className="app-content">
<Content> <Content>
<Suspense fallback={<Loader />}> <Suspense fallback={<Loader />}>{element}</Suspense>
{element}
</Suspense>
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
); );
}, []); }, []);
if (isLoading) {
return <Loader />;
}
return ( return (
<> <>
<Layout> <Layout>
@ -111,11 +156,16 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
<Route path={`${Routes.TUNE_TUNE}/*`} element={<ContentFor marginLeft={margin} element={<Tune />} />} /> <Route path={`${Routes.TUNE_TUNE}/*`} element={<ContentFor marginLeft={margin} element={<Tune />} />} />
<Route path={Routes.TUNE_LOGS} element={<ContentFor marginLeft={margin} element={<Logs />} />} /> <Route path={Routes.TUNE_LOGS} element={<ContentFor marginLeft={margin} element={<Logs />} />} />
<Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} /> <Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} />
<Route path={`${Routes.UPLOAD}/*`} element={<ContentFor element={<Upload />} />} />
<Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} /> <Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} />
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} /> <Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} /> <Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} /> <Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
<Route path={Routes.UPLOAD} element={<ContentFor element={<Upload />} />} />
<Route path={Routes.MAGIC_LINK_CONFIRMATION} element={<ContentFor element={<MagicLinkConfirmation />} />} />
<Route path={Routes.EMAIL_VERIFICATION} element={<ContentFor element={<EmailVerification />} />} />
<Route path={Routes.RESET_PASSWORD_CONFIRMATION} element={<ContentFor element={<ResetPasswordConfirmation />} />} />
</ReactRoutes> </ReactRoutes>
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} /> <Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
</Layout> </Layout>

24
src/appwrite.ts Normal file
View File

@ -0,0 +1,24 @@
import {
Account,
Client,
Databases,
Storage,
} from 'appwrite';
import { fetchEnv } from './utils/env';
const client = new Client();
client
.setEndpoint(fetchEnv('VITE_APPWRITE_ENDPOINT'))
.setProject(fetchEnv('VITE_APPWRITE_PROJECT_ID'));
const account = new Account(client);
const database = new Databases(client, fetchEnv('VITE_APPWRITE_DATABASE_ID'));
const storage = new Storage(client);
export {
client,
account,
database,
storage,
};

View File

@ -75,8 +75,8 @@ const mapStateToProps = (state: AppState) => ({
}); });
interface CommandPaletteProps { interface CommandPaletteProps {
config: ConfigType; config: ConfigType | null;
tune: TuneType; tune: TuneType | null;
navigation: NavigationState; navigation: NavigationState;
// eslint-disable-next-line react/no-unused-prop-types // eslint-disable-next-line react/no-unused-prop-types
children?: ReactNode; children?: ReactNode;
@ -289,14 +289,14 @@ const ActionsProvider = (props: CommandPaletteProps) => {
}, [navigate, navigation.tuneId]); }, [navigate, navigation.tuneId]);
const getActions = () => { const getActions = () => {
if (Object.keys(tune.constants).length) { if (tune?.constants && Object.keys(tune.constants).length) {
return generateActions(config.menus); return generateActions(config!.menus);
} }
return []; return [];
}; };
useRegisterActions(getActions(), [tune.constants]); useRegisterActions(getActions(), [tune?.constants]);
return null; return null;
}; };

View File

@ -52,7 +52,7 @@ const StatusBar = ({ tune }: { tune: TuneState }) => (
<Footer className="app-status-bar"> <Footer className="app-status-bar">
<Row> <Row>
<Col span={20}> <Col span={20}>
{tune.details.author && <Firmware tune={tune} />} {tune?.details?.author && <Firmware tune={tune} />}
</Col> </Col>
<Col span={4} style={{ textAlign: 'right' }}> <Col span={4} style={{ textAlign: 'right' }}>
<a <a

View File

@ -166,6 +166,28 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
return list.length ? list : null; return list.length ? list : null;
}, [lg, sm]); }, [lg, sm]);
const userMenuItems = useMemo(() => currentUser ? [{
key: 'profile',
icon: <UserOutlined />,
label: 'Profile',
onClick: () => navigate(Routes.PROFILE),
}, {
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: logoutClick,
}] : [{
key: 'login',
icon: <LoginOutlined />,
label: 'Login',
onClick: () => navigate(Routes.LOGIN),
}, {
key: 'sign-up',
icon: <UserAddOutlined />,
label: 'Sign Up',
onClick: () => navigate(Routes.SIGN_UP),
}], [currentUser, logoutClick, navigate]);
return ( return (
<Header className="app-top-bar"> <Header className="app-top-bar">
<Row> <Row>
@ -225,30 +247,8 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
</Button> </Button>
</Dropdown> </Dropdown>
<Dropdown <Dropdown
overlay={ overlay={<Menu items={userMenuItems} />}
<Menu> placement="bottomRight"
{currentUser ? (
<>
<Menu.Item key="profile" icon={<UserOutlined />}>
<Link to={Routes.PROFILE}>Profile</Link>
</Menu.Item>
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={logoutClick}>
Logout
</Menu.Item>
</>
) : (
<>
<Menu.Item key="login" icon={<LoginOutlined />}>
<Link to={Routes.LOGIN}>Login</Link>
</Menu.Item>
<Menu.Item key="sign-up" icon={<UserAddOutlined />}>
<Link to={Routes.SIGN_UP}>Sign Up</Link>
</Menu.Item>
</>
)}
</Menu>
}
placement="bottom"
trigger={['click']} trigger={['click']}
> >
<Button icon={<UserOutlined />}> <Button icon={<UserOutlined />}>

View File

@ -96,7 +96,7 @@ const Dialog = ({
name: string, name: string,
url: string, url: string,
}) => { }) => {
const isDataReady = Object.keys(tune.constants).length && Object.keys(config.constants).length; const isDataReady = tune && config && Object.keys(tune.constants).length && Object.keys(config.constants).length;
const { storageSet } = useBrowserStorage(); const { storageSet } = useBrowserStorage();
const { findConstantOnPage } = useConfig(config); const { findConstantOnPage } = useConfig(config);
const [panelsComponents, setPanelsComponents] = useState<any[]>([]); const [panelsComponents, setPanelsComponents] = useState<any[]>([]);
@ -138,7 +138,7 @@ const Dialog = ({
yData={parseXy(y.value as string)} yData={parseXy(y.value as string)}
/> />
); );
}, [config.help, findConstantOnPage, tune.constants]); }, [config?.help, findConstantOnPage, tune?.constants]);
const renderTable = useCallback((table: TableType | RenderedPanel) => { const renderTable = useCallback((table: TableType | RenderedPanel) => {
const x = tune.constants[table.xBins[0]]; const x = tune.constants[table.xBins[0]];
@ -157,7 +157,7 @@ const Dialog = ({
yUnits={y.units as string} yUnits={y.units as string}
/> />
</div>; </div>;
}, [tune.constants]); }, [tune?.constants]);
const calculateSpan = (type: PanelTypes, dialogsCount: number) => { const calculateSpan = (type: PanelTypes, dialogsCount: number) => {
let xxl = 24; let xxl = 24;
@ -221,7 +221,7 @@ const Dialog = ({
}); });
}; };
if (config.dialogs) { if (config?.dialogs) {
resolveDialogs(config.dialogs, name); resolveDialogs(config.dialogs, name);
} }
@ -340,7 +340,7 @@ const Dialog = ({
{panel.type === PanelTypes.TABLE && renderTable(panel)} {panel.type === PanelTypes.TABLE && renderTable(panel)}
</Col> </Col>
); );
}), [config, findConstantOnPage, panels, renderCurve, renderTable, tune.constants]); }), [config, findConstantOnPage, panels, renderCurve, renderTable, tune?.constants]);
useEffect(() => { useEffect(() => {
storageSet('lastDialog', url); storageSet('lastDialog', url);

View File

@ -61,7 +61,7 @@ const Curve = ({
value: (_self, val) => `${val.toLocaleString()}${yUnits}`, value: (_self, val) => `${val.toLocaleString()}${yUnits}`,
points: { show: true }, points: { show: true },
stroke: Colors.ACCENT, stroke: Colors.ACCENT,
width: 2, width: 3,
}, },
], ],
axes: [ axes: [

View File

@ -2,11 +2,12 @@ import {
Layout, Layout,
Menu, Menu,
} from 'antd'; } from 'antd';
import { ItemType } from 'antd/lib/menu/hooks/useItems';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
generatePath, generatePath,
Link,
PathMatch, PathMatch,
useNavigate,
} from 'react-router-dom'; } from 'react-router-dom';
import PerfectScrollbar from 'react-perfect-scrollbar'; import PerfectScrollbar from 'react-perfect-scrollbar';
import { import {
@ -29,7 +30,6 @@ import {
} from '../../types/state'; } from '../../types/state';
const { Sider } = Layout; const { Sider } = Layout;
const { SubMenu } = Menu;
export const SKIP_MENUS = [ export const SKIP_MENUS = [
'help', 'help',
@ -59,8 +59,8 @@ const mapStateToProps = (state: AppState) => ({
}); });
interface SideBarProps { interface SideBarProps {
config: ConfigType; config: ConfigType | null;
tune: TuneType; tune: TuneType | null;
ui: UIState; ui: UIState;
navigation: NavigationState; navigation: NavigationState;
matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'>; matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'>;
@ -75,50 +75,49 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
collapsed: ui.sidebarCollapsed, collapsed: ui.sidebarCollapsed,
onCollapse: (collapsed: boolean) => store.dispatch({ type: 'ui/sidebarCollapsed', payload: collapsed }), onCollapse: (collapsed: boolean) => store.dispatch({ type: 'ui/sidebarCollapsed', payload: collapsed }),
} as any; } as any;
const [menus, setMenus] = useState<any[]>([]); const [menus, setMenus] = useState<ItemType[]>([]);
const navigate = useNavigate();
const menusList = useCallback((types: MenusType) => ( const menusList = useCallback((types: MenusType): ItemType[] => (
Object.keys(types).map((menuName: string) => { Object.keys(types).map((menuName: string) => {
if (SKIP_MENUS.includes(menuName)) { if (SKIP_MENUS.includes(menuName)) {
return null; return null;
} }
return ( const subMenuItems: ItemType[] = Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
<SubMenu if (subMenuName === 'std_separator') {
key={`/${menuName}`} return { type: 'divider' };
icon={<Icon name={menuName} />} }
title={types[menuName].title}
onTitleClick={() => store.dispatch({ type: 'ui/sidebarCollapsed', payload: false })}
>
{Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
if (subMenuName === 'std_separator') {
return <Menu.Divider key={buildUrl(navigation.tuneId!, menuName, subMenuName)} />;
}
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) { if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
return null; return null;
} }
const subMenu = types[menuName].subMenus[subMenuName];
return (<Menu.Item const subMenu = types[menuName].subMenus[subMenuName];
key={buildUrl(navigation.tuneId!, menuName, subMenuName)}
icon={<Icon name={subMenuName} />} return {
> key: buildUrl(navigation.tuneId!, menuName, subMenuName),
<Link to={buildUrl(navigation.tuneId!, menuName, subMenuName)}> icon: <Icon name={subMenuName} />,
{subMenu.title} label: subMenu.title,
</Link> onClick: () => navigate(buildUrl(navigation.tuneId!, menuName, subMenuName)),
</Menu.Item>); };
})} });
</SubMenu>
); return {
key: `/${menuName}`,
icon: <Icon name={menuName} />,
label: types[menuName].title,
onClick: () => ui.sidebarCollapsed && store.dispatch({ type: 'ui/sidebarCollapsed', payload: false }),
children: subMenuItems,
};
}) })
), [navigation.tuneId]); ), [navigate, navigation.tuneId, ui.sidebarCollapsed]);
useEffect(() => { useEffect(() => {
if (Object.keys(tune.constants).length) { if (tune && config && Object.keys(tune.constants).length) {
setMenus(menusList(config.menus)); setMenus(menusList(config.menus));
} }
}, [config.menus, menusList, tune.constants]); }, [config, config?.menus, menusList, tune, tune?.constants]);
return ( return (
<Sider {...siderProps} className="app-sidebar"> <Sider {...siderProps} className="app-sidebar">
@ -129,9 +128,8 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
mode="inline" mode="inline"
style={{ height: '100%' }} style={{ height: '100%' }}
key={matchedPath.pathname} key={matchedPath.pathname}
> items={menus}
{menus} />
</Menu>
</PerfectScrollbar> </PerfectScrollbar>
</Sider> </Sider>
); );

View File

@ -1,15 +1,3 @@
import {
User,
UserCredential,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
sendEmailVerification,
signOut,
sendPasswordResetEmail,
GoogleAuthProvider,
GithubAuthProvider,
signInWithPopup,
} from 'firebase/auth';
import { import {
createContext, createContext,
ReactNode, ReactNode,
@ -18,19 +6,118 @@ import {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { auth } from '../firebase'; import {
account,
client,
} from '../appwrite';
import Loader from '../components/Loader';
import { Routes } from '../routes';
import {
buildFullUrl,
buildRedirectUrl,
} from '../utils/url';
export interface User {
$id: string;
name: string;
registration: number;
status: boolean;
passwordUpdate: number;
email: string;
emailVerification: boolean;
prefs: {};
}
export interface Session {
$id: string;
userId: string;
expire: number;
provider: string;
providerUid: string;
providerAccessToken: string;
providerAccessTokenExpiry: number;
providerRefreshToken: string;
ip: string;
osCode: string;
osName: string;
osVersion: string;
clientType: string;
clientCode: string;
clientName: string;
clientVersion: string;
clientEngine: string;
clientEngineVersion: string;
deviceName: string;
deviceBrand: string;
deviceModel: string;
countryCode: string;
countryName: string;
current: boolean;
};
export interface SessionList {
sessions: Session[];
total: number;
};
export interface Log {
event: string;
userId: string;
userEmail: string;
userName: string;
mode: string;
ip: string;
time: number;
osCode: string;
osName: string;
osVersion: string;
clientType: string;
clientCode: string;
clientName: string;
clientVersion: string;
clientEngine: string;
clientEngineVersion: string;
deviceName: string;
deviceBrand: string;
deviceModel: string;
countryCode: string;
countryName: string;
};
export interface LogList {
logs: Log[];
total: number;
}
interface AuthValue { interface AuthValue {
currentUser: User | null, currentUser: User | null,
signUp: (email: string, password: string) => Promise<void>, signUp: (email: string, password: string, username: string) => Promise<User>,
login: (email: string, password: string) => Promise<UserCredential>, login: (email: string, password: string) => Promise<User>,
sendMagicLink: (email: string) => Promise<void>,
confirmMagicLink: (userId: string, secret: string) => Promise<User>,
sendEmailVerification: () => Promise<void>,
confirmEmailVerification: (userId: string, secret: string) => Promise<void>,
confirmResetPassword: (userId: string, secret: string, password: string) => Promise<void>,
logout: () => Promise<void>, logout: () => Promise<void>,
resetPassword: (email: string) => Promise<void>, initResetPassword: (email: string) => Promise<void>,
googleAuth: () => Promise<void>, googleAuth: () => Promise<void>,
githubAuth: () => Promise<void>, githubAuth: () => Promise<void>,
refreshToken: () => Promise<string> | undefined, facebookAuth: () => Promise<void>,
updateUsername: (username: string) => Promise<void>,
updatePassword: (password: string, oldPassword: string) => Promise<void>,
getSessions: () => Promise<SessionList>,
getLogs: () => Promise<LogList>,
} }
const OAUTH_REDIRECT_URL = buildFullUrl();
const MAGIC_LINK_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION);
const EMAIL_VERIFICATION_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_EMAIL_VERIFICATION);
const RESET_PASSWORD_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_RESET_PASSWORD);
const GOOGLE_SCOPES = ['https://www.googleapis.com/auth/userinfo.email'];
const GITHUB_SCOPES = ['user:email'];
const FACEBOOK_SCOPES = ['email'];
const AuthContext = createContext<AuthValue | null>(null); const AuthContext = createContext<AuthValue | null>(null);
const useAuth = () => useContext<AuthValue>(AuthContext as any); const useAuth = () => useContext<AuthValue>(AuthContext as any);
@ -42,28 +129,145 @@ const AuthProvider = (props: { children: ReactNode }) => {
const value = useMemo(() => ({ const value = useMemo(() => ({
currentUser, currentUser,
signUp: (email: string, password: string) => createUserWithEmailAndPassword(auth, email, password) signUp: async (email: string, password: string, username: string) => {
.then((userCredential) => sendEmailVerification(userCredential.user)), try {
login: (email: string, password: string) => signInWithEmailAndPassword(auth, email, password), await account.create('unique()', email, password, username);
logout: () => signOut(auth), await account.createEmailSession(email, password);
resetPassword: (email: string) => sendPasswordResetEmail(auth, email), const user = await account.get();
setCurrentUser(user);
return Promise.resolve(user);
} catch (error) {
return Promise.reject(error);
}
},
login: async (email: string, password: string) => {
try {
await account.createEmailSession(email, password);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve(user);
} catch (error) {
return Promise.reject(error);
}
},
sendMagicLink: async (email: string) => {
try {
await account.createMagicURLSession('unique()', email, MAGIC_LINK_REDIRECT_URL);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
confirmMagicLink: async (userId: string, secret: string) => {
try {
await account.updateMagicURLSession(userId, secret);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve(user);
} catch (error) {
return Promise.reject(error);
}
},
sendEmailVerification: async () => {
try {
await account.createVerification(EMAIL_VERIFICATION_REDIRECT_URL);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
confirmEmailVerification: async (userId: string, secret: string) => {
try {
await account.updateVerification(userId, secret);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
confirmResetPassword: async (userId: string, secret: string, password: string) => {
try {
await account.updateRecovery(userId, secret, password, password);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
logout: async () => {
try {
await account.deleteSession('current');
setCurrentUser(null);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
initResetPassword: async (email: string) => {
try {
await account.createRecovery(email, RESET_PASSWORD_REDIRECT_URL);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
googleAuth: async () => { googleAuth: async () => {
const provider = new GoogleAuthProvider().addScope('https://www.googleapis.com/auth/userinfo.email'); account.createOAuth2Session(
const credentials = await signInWithPopup(auth, provider); 'google',
setCurrentUser(credentials.user); OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
GOOGLE_SCOPES,
);
}, },
githubAuth: async () => { githubAuth: async () => {
const provider = new GithubAuthProvider().addScope('user:email'); account.createOAuth2Session(
const credentials = await signInWithPopup(auth, provider); 'github',
setCurrentUser(credentials.user); OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
GITHUB_SCOPES,
);
}, },
refreshToken: () => auth.currentUser?.getIdToken(true), facebookAuth: async () => {
account.createOAuth2Session(
'facebook',
OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
FACEBOOK_SCOPES,
);
},
updateUsername: async (username: string) => {
try {
await account.updateName(username);
const user = await account.get();
setCurrentUser(user);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
updatePassword: async (password: string, oldPassword: string) => {
try {
await account.updatePassword(password, oldPassword);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
},
getSessions: () => account.getSessions(),
getLogs: () => account.getLogs(),
}), [currentUser]); }), [currentUser]);
useEffect(() => { useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => { account.get().then((user) => {
console.info('Logged as:', user.name || 'Unknown');
setCurrentUser(user); setCurrentUser(user);
setIsLoading(false); setIsLoading(false);
}).catch(() => {
console.info('User not logged in');
}).finally(() => setIsLoading(false));
const unsubscribe = client.subscribe('account', (event) => {
console.info('Account event', event);
}); });
return unsubscribe; return unsubscribe;
@ -71,7 +275,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
return ( return (
<AuthContext.Provider value={value}> <AuthContext.Provider value={value}>
{!isLoading && children} {isLoading ? <Loader /> : children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };

View File

@ -4,14 +4,7 @@
@import './themes/dark.less'; @import './themes/dark.less';
@import './themes/common.less'; @import './themes/common.less';
@import './themes/ant.less'; @import './themes/ant.less';
@import './overrides.less';
:root {
--background: @component-background;
--foreground: @text;
--a1: @main;
--border: @border-color-split;
--shadow: @shadow-2;
}
body { body {
overflow: hidden; overflow: hidden;
@ -36,13 +29,6 @@ html, body {
z-index: @bars-z-index; z-index: @bars-z-index;
} }
.app-sidebar {
height: calc(100vh - @layout-header-height - @layout-footer-height);
position: fixed;
left: 0;
user-select: none;
}
.app-status-bar { .app-status-bar {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
@ -58,12 +44,25 @@ html, body {
} }
} }
.app-sidebar {
height: calc(100vh - @layout-header-height - @layout-footer-height);
position: fixed;
left: 0;
user-select: none;
}
.app-content { .app-content {
height: calc(100vh - @layout-header-height - @layout-footer-height); height: calc(100vh - @layout-header-height - @layout-footer-height);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.ant-tabs-tabpane {
height: calc(100vh - @layout-header-height - @layout-footer-height - @layout-trigger-height - @tabs-nav-height);
overflow-y: auto;
overflow-x: hidden;
}
.small-container, .small-container,
.large-container, .large-container,
.auth-container { .auth-container {
@ -83,25 +82,10 @@ html, body {
max-width: 1400px; max-width: 1400px;
} }
.ant-tabs-tabpane {
height: calc(100vh - @layout-header-height - @layout-footer-height - @layout-trigger-height - @tabs-nav-height);
overflow-y: auto;
overflow-x: hidden;
}
.ant-checkbox-wrapper { .ant-checkbox-wrapper {
user-select: none; user-select: none;
} }
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-delete,
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-eye {
color: @text;
}
.table { .table {
margin: 20px; margin: 20px;

23
src/css/overrides.less Normal file
View File

@ -0,0 +1,23 @@
// ant design
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-delete,
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-eye {
color: @text;
}
// kbar
:root {
--background: @component-background;
--foreground: @text;
--a1: @main;
--border: @border-color-split;
--shadow: @shadow-2;
}
reach-portal > div {
z-index: 1;
backdrop-filter: blur(3px);
}

View File

@ -1,4 +1,4 @@
@primary-color: #126ec3; @primary-color: #2F49D1;
@text-light: #fff; @text-light: #fff;
@border-radius-base: 6px; @border-radius-base: 6px;

View File

@ -1,9 +1,9 @@
// darker // darker
@main: #222629; @text: #CECECE;
@main-dark: #191C1E; @main: #191C1E;
@main-light: #2E3338; @main-dark: #1E1E1E;
@main-light: #252525;
@text: #ddd; @main-darkest: #171717;
// lighter // lighter
// @main: #272c30; // @main: #272c30;

View File

@ -1,26 +0,0 @@
import { initializeApp } from 'firebase/app';
import { getPerformance } from 'firebase/performance';
import { getAuth } from 'firebase/auth';
import { getAnalytics } from 'firebase/analytics';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET as string,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string,
appId: import.meta.env.VITE_FIREBASE_APP_ID as string,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID as string,
};
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
const performance = getPerformance(app);
const auth = getAuth(app);
export {
app,
analytics,
performance,
auth,
};

View File

@ -48,12 +48,12 @@ const findDatalog = (config: ConfigType, name: string): DatalogEntry => {
return result; return result;
}; };
const useConfig = (config: ConfigType) => useMemo(() => ({ const useConfig = (config: ConfigType | null) => useMemo(() => ({
isConfigReady: !!config.constants, isConfigReady: !!config?.constants,
findOutputChannel: (name: string) => findOutputChannel(config, name), findOutputChannel: (name: string) => findOutputChannel(config!, name),
findConstantOnPage: (name: string) => findConstantOnPage(config, name), findConstantOnPage: (name: string) => findConstantOnPage(config!, name),
findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config, label), findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config!, label),
findDatalog: (name: string) => findDatalog(config, name), findDatalog: (name: string) => findDatalog(config!, name),
}), [config]); }), [config]);
export default useConfig; export default useConfig;

View File

@ -1,84 +1,126 @@
import { notification } from 'antd';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { import {
Timestamp, Models,
doc, Query,
getDoc, } from 'appwrite';
setDoc, import { database } from '../appwrite';
collection, import {
where, TuneDbData,
query, UsersBucket,
getDocs, TuneDbDataPartial,
QuerySnapshot, TuneDbDocument,
orderBy, } from '../types/dbData';
getFirestore, import { databaseGenericError } from '../pages/auth/notifications';
} from 'firebase/firestore/lite'; import { fetchEnv } from '../utils/env';
import { TuneDbData } from '../types/dbData';
const TUNES_PATH = 'publicTunes'; const COLLECTION_ID_PUBLIC_TUNES = fetchEnv('VITE_APPWRITE_COLLECTION_ID_PUBLIC_TUNES');
const COLLECTION_ID_USERS_BUCKETS = fetchEnv('VITE_APPWRITE_COLLECTION_ID_USERS_BUCKETS');
const db = getFirestore();
const genericError = (error: Error) => notification.error({ message: 'Database Error', description: error.message });
const useDb = () => { const useDb = () => {
const getTuneData = async (tuneId: string) => { const updateTune = async (documentId: string, data: TuneDbDataPartial) => {
try { try {
const tune = (await getDoc(doc(db, TUNES_PATH, tuneId))).data() as TuneDbData; await database.updateDocument(COLLECTION_ID_PUBLIC_TUNES, documentId, data);
const processed = {
...tune,
createdAt: (tune?.createdAt as Timestamp)?.toDate().toISOString(),
updatedAt: (tune?.updatedAt as Timestamp)?.toDate().toISOString(),
};
return Promise.resolve(processed);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const listTunesData = async () => {
try {
const tunesRef = collection(db, TUNES_PATH);
const q = query(
tunesRef,
where('isPublished', '==', true),
where('isListed', '==', true),
orderBy('createdAt', 'desc'),
);
return Promise.resolve(await getDocs(q));
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const updateData = async (tuneId: string, data: TuneDbData) => {
try {
await setDoc(doc(db, TUNES_PATH, tuneId), data, { merge: true });
return Promise.resolve(); return Promise.resolve();
} catch (error) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
console.error(error); console.error(error);
genericError(error as Error); databaseGenericError(error as Error);
return Promise.reject(error);
}
};
const createTune = async (data: TuneDbData) => {
try {
const tune = await database.createDocument(
COLLECTION_ID_PUBLIC_TUNES,
'unique()',
data,
['role:all'],
[`user:${data.userId}`],
);
return Promise.resolve(tune);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
return Promise.reject(error);
}
};
const getTune = async (tuneId: string) => {
try {
const tune = await database.listDocuments(
COLLECTION_ID_PUBLIC_TUNES,
[Query.equal('tuneId', tuneId)],
1,
);
return Promise.resolve(tune.total > 0 ? tune.documents[0] as unknown as TuneDbDocument : null);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
return Promise.reject(error);
}
};
const getBucketId = async (userId: string) => {
try {
const buckets = await database.listDocuments(
COLLECTION_ID_USERS_BUCKETS,
[
Query.equal('userId', userId),
Query.equal('visibility', 'public'),
],
1,
);
if (buckets.total === 0) {
throw new Error('No public bucket found');
}
return Promise.resolve((buckets.documents[0] as unknown as UsersBucket)!.bucketId);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
return Promise.reject(error);
}
};
const searchTunes = async (search?: string) => {
// TODO: add pagination
const limit = 100;
try {
const list: Models.DocumentList<TuneDbDocument> = await (
search
? database.listDocuments(COLLECTION_ID_PUBLIC_TUNES, [Query.search('textSearch', search)], limit)
: database.listDocuments(COLLECTION_ID_PUBLIC_TUNES, [], limit)
);
return Promise.resolve(list);
} catch (error) {
Sentry.captureException(error);
console.error(error);
databaseGenericError(error as Error);
return Promise.reject(error); return Promise.reject(error);
} }
}; };
return { return {
updateData: (tuneId: string, data: TuneDbData): Promise<void> => updateData(tuneId, data), updateTune: (tuneId: string, data: TuneDbDataPartial): Promise<void> => updateTune(tuneId, data),
getTune: (tuneId: string): Promise<TuneDbData> => getTuneData(tuneId), createTune: (data: TuneDbData): Promise<Models.Document> => createTune(data),
listTunes: (): Promise<QuerySnapshot<TuneDbData>> => listTunesData(), getTune: (tuneId: string): Promise<TuneDbDocument | null> => getTune(tuneId),
searchTunes: (search?: string): Promise<Models.DocumentList<TuneDbDocument>> => searchTunes(search),
getBucketId: (userId: string): Promise<string> => getBucketId(userId),
}; };
}; };

View File

@ -1,46 +1,23 @@
import { notification } from 'antd'; import { notification } from 'antd';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { import { Models } from 'appwrite';
UploadTask, import { storage } from '../appwrite';
ref, import { fetchEnv } from '../utils/env';
getBytes,
deleteObject,
uploadBytesResumable,
getStorage,
} from 'firebase/storage';
const PUBLIC_PATH = 'public'; const PUBLIC_PATH = 'public';
const USERS_PATH = `${PUBLIC_PATH}/users`;
const INI_PATH = `${PUBLIC_PATH}/ini`; const INI_PATH = `${PUBLIC_PATH}/ini`;
export const CDN_URL = import.meta.env.VITE_CDN_URL; export const CDN_URL = fetchEnv('VITE_CDN_URL');
const storage = getStorage(); export type ServerFile = Models.File;
const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message }); const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message });
const fetchFromServer = async (path: string): Promise<ArrayBuffer> => { const fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
if (CDN_URL) { const response = await fetch(`${CDN_URL}/${path}`);
const response = await fetch(`${CDN_URL}/${path}`); return Promise.resolve(response.arrayBuffer());
return Promise.resolve(response.arrayBuffer());
}
return Promise.resolve(await getBytes(ref(storage, path)));
}; };
const useServerStorage = () => { const useServerStorage = () => {
const getFile = async (path: string) => {
try {
return fetchFromServer(path);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getINIFile = async (signature: string) => { const getINIFile = async (signature: string) => {
const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null }; const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
@ -52,7 +29,7 @@ const useServerStorage = () => {
notification.warning({ notification.warning({
message: 'INI not found', message: 'INI not found',
description: `INI version: "${version}" not found. Trying base version: "${baseVersion}"!` , description: `INI version: "${version}" not found. Trying base version: "${baseVersion}"!`,
}); });
try { try {
@ -63,7 +40,7 @@ const useServerStorage = () => {
notification.error({ notification.error({
message: 'INI not found', message: 'INI not found',
description: `INI version: "${baseVersion}" not found. Try uploading custom INI file!` , description: `INI version: "${baseVersion}" not found. Try uploading custom INI file!`,
}); });
} }
@ -71,10 +48,9 @@ const useServerStorage = () => {
} }
}; };
const removeFile = async (path: string) => { const removeFile = async (bucketId: string, fileId: string) => {
try { try {
await deleteObject(ref(storage, path)); await storage.deleteFile(bucketId, fileId);
return Promise.resolve(); return Promise.resolve();
} catch (error) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
@ -85,20 +61,61 @@ const useServerStorage = () => {
} }
}; };
const uploadFile = (path: string, file: File, data: Uint8Array) => const uploadFile = async (userId: string, bucketId: string, file: File) => {
uploadBytesResumable(ref(storage, path), data, { try {
customMetadata: { const createdFile = await storage.createFile(
name: file.name, bucketId,
size: `${file.size}`, 'unique()',
}, file,
}); ['role:all'],
[`user:${userId}`],
);
return Promise.resolve(createdFile);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getFile = async (id: string, bucketId: string) => {
try {
const file = await storage.getFile(bucketId, id);
return Promise.resolve(file);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
const getFileForDownload = async (id: string, bucketId: string) => {
try {
const file = storage.getFileView(bucketId, id);
const response = await fetch(file.href);
return Promise.resolve(response.arrayBuffer());
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
return Promise.reject(error);
}
};
return { return {
getFile: (path: string): Promise<ArrayBuffer> => getFile(path), getFile: (id: string, bucketId: string): Promise<Models.File> => getFile(id, bucketId),
getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature), getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature),
removeFile: (path: string): Promise<void> => removeFile(path), removeFile: (bucketId: string, fileId: string): Promise<void> => removeFile(bucketId, fileId),
uploadFile: (path: string, file: File, data: Uint8Array): UploadTask => uploadFile(path, file, data), getFileForDownload: (bucketId: string, fileId: string): Promise<ArrayBuffer> => getFileForDownload(bucketId, fileId),
basePathForFile: (userUuid: string, tuneId: string, fileName: string): string => `${USERS_PATH}/${userUuid}/tunes/${tuneId}/${fileName}`, uploadFile: (userId: string, bucketId: string, file: File): Promise<ServerFile> => uploadFile(userId, bucketId, file),
}; };
}; };

View File

@ -25,11 +25,12 @@ import {
generatePath, generatePath,
useNavigate, useNavigate,
} from 'react-router'; } from 'react-router';
import { Timestamp } from 'firebase/firestore/lite'; import debounce from 'lodash.debounce';
import useDb from '../hooks/useDb'; import useDb from '../hooks/useDb';
import { TuneDbData } from '../types/dbData'; import { TuneDbDocument } from '../types/dbData';
import { Routes } from '../routes'; import { Routes } from '../routes';
import { generateShareUrl } from '../utils/url'; import { buildFullUrl } from '../utils/url';
import { aspirationMapper } from '../utils/tune/mappers';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@ -47,17 +48,16 @@ const loadingCards = (
</> </>
); );
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const Hub = () => { const Hub = () => {
const { md } = useBreakpoint(); const { md } = useBreakpoint();
const { listTunes } = useDb(); const { searchTunes } = useDb();
const navigate = useNavigate(); const navigate = useNavigate();
const [tunes, setTunes] = useState<TuneDbData[]>([]); const [dataSource, setDataSource] = useState<any>([]);
const [dataSource, setDataSource] = useState<any[]>([]);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const goToTune = (tuneId: string) => navigate(generatePath(Routes.TUNE_TUNE, { tuneId }));
const copyToClipboard = async (shareUrl: string) => { const copyToClipboard = async (shareUrl: string) => {
if (navigator.clipboard) { if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
@ -66,44 +66,61 @@ const Hub = () => {
} }
}; };
const loadData = useCallback(() => { const loadData = debounce(async (searchText?: string) => {
listTunes().then((data) => { setIsLoading(true);
const temp: TuneDbData[] = []; const list = await searchTunes(searchText);
// TODO: create `unpublishedTunes` collection for this
const filtered = list.documents.filter((tune) => !!tune.vehicleName);
setDataSource(filtered.map((tune) => ({
...tune,
key: tune.tuneId,
year: tune.year,
author: '?',
displacement: `${tune.displacement}l`,
aspiration: aspirationMapper[tune.aspiration],
updatedAt: new Date(tune.$updatedAt * 1000).toLocaleString(),
stars: 0,
})));
setIsLoading(false);
}, 300);
data.forEach((tuneSnapshot) => { const debounceLoadData = useCallback((value: string) => loadData(value), [loadData]);
temp.push(tuneSnapshot.data());
});
setTunes(temp); useEffect(() => {
setDataSource(temp.map((tune) => ({ loadData();
key: tune.id, // eslint-disable-next-line react-hooks/exhaustive-deps
tuneId: tune.id, }, []); // TODO: fix this
make: tune.details!.make,
model: tune.details!.model,
year: tune.details!.year,
author: 'karniv00l',
publishedAt: new Date((tune.createdAt as Timestamp).seconds * 1000).toLocaleString(),
stars: 0,
})));
setIsLoading(false);
});
}, [listTunes]);
const columns = [ const columns = [
{ {
title: 'Make', title: 'Vehicle name',
dataIndex: 'make', dataIndex: 'vehicleName',
key: 'make', key: 'vehicleName',
}, },
{ {
title: 'Model', title: 'Engine make',
dataIndex: 'model', dataIndex: 'engineMake',
key: 'model', key: 'engineMake',
}, },
{ {
title: 'Year', title: 'Engine code',
dataIndex: 'year', dataIndex: 'engineCode',
key: 'year', key: 'engineCode',
},
{
title: 'Displacement',
dataIndex: 'displacement',
key: 'displacement',
},
{
title: 'Cylinders',
dataIndex: 'cylindersCount',
key: 'cylindersCount',
},
{
title: 'Aspiration',
dataIndex: 'aspiration',
key: 'aspiration',
}, },
{ {
title: 'Author', title: 'Author',
@ -112,8 +129,8 @@ const Hub = () => {
}, },
{ {
title: 'Published', title: 'Published',
dataIndex: 'publishedAt', dataIndex: 'updatedAt',
key: 'publishedAt', key: 'updatedAt',
}, },
{ {
title: <StarOutlined />, title: <StarOutlined />,
@ -125,45 +142,45 @@ const Hub = () => {
render: (tuneId: string) => ( render: (tuneId: string) => (
<Space> <Space>
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}> <Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(generateShareUrl(tuneId))} /> <Button icon={<CopyOutlined />} onClick={() => copyToClipboard(buildFullUrl([tunePath(tuneId)]))} />
</Tooltip> </Tooltip>
<Button icon={<ArrowRightOutlined />} onClick={() => goToTune(tuneId)} /> <Button type="primary" icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tuneId))} />
</Space> </Space>
), ),
key: 'tuneId', key: 'tuneId',
}, },
]; ];
useEffect(() => {
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // TODO: fix this
return ( return (
<div className="large-container"> <div className="large-container">
<Typography.Title>Hub</Typography.Title> <Typography.Title>Hub</Typography.Title>
<Input style={{ marginBottom: 10, height: 40 }} placeholder="Search..." /> <Input
tabIndex={0}
style={{ marginBottom: 10, height: 40 }}
placeholder="Search..."
onChange={({ target }) => debounceLoadData(target.value)}
/>
{md ? {md ?
<Table dataSource={dataSource} columns={columns} loading={isLoading} /> <Table dataSource={dataSource} columns={columns} loading={isLoading} pagination={false} />
: :
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{isLoading ? loadingCards : ( {isLoading ? loadingCards : (
tunes.map((tune) => ( dataSource.map((tune: TuneDbDocument) => (
<Col span={16} sm={8} key={tune.tuneFile}> <Col span={16} sm={8} key={tune.tuneFile}>
<Card <Card
title={tune.details!.model} title={tune.vehicleName}
actions={[ actions={[
<Badge count={0} showZero size="small" color="gold"> <Badge count={0} showZero size="small" color="gold">
<StarOutlined /> <StarOutlined />
</Badge>, </Badge>,
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}> <Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
<CopyOutlined onClick={() => copyToClipboard(generateShareUrl(tune.id!))} /> <CopyOutlined onClick={() => copyToClipboard(buildFullUrl([tunePath(tune.id!)]))} />
</Tooltip>, </Tooltip>,
<ArrowRightOutlined onClick={() => goToTune(tune.id!)} />, <ArrowRightOutlined onClick={() => navigate(tunePath(tune.id!))} />,
]} ]}
> >
<Typography.Text ellipsis> <Typography.Text ellipsis>
{tune.details!.make} {tune.details!.model} {tune.details!.year} {tune.engineMake} {tune.engineCode} {tune.year}
</Typography.Text> </Typography.Text>
</Card> </Card>
</Col> </Col>

View File

@ -23,7 +23,7 @@ const mapStateToProps = (state: AppState) => ({
}); });
const Info = ({ tuneData }: { tuneData: TuneDataState }) => { const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
if (!tuneData.details) { if (!tuneData?.vehicleName) {
return <Loader />; return <Loader />;
} }
@ -32,89 +32,96 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
<Divider>Details</Divider> <Divider>Details</Divider>
<Form> <Form>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col span={24} sm={24}>
<Item> <Item>
<Input value={tuneData.details.make!} addonBefore="Make" /> <Input value={tuneData.vehicleName!} addonBefore="Vehicle name" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.details.model!} addonBefore="Model" />
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.year!} addonBefore="Year" style={{ width: '100%' }} /> <Input value={tuneData.engineMake!} addonBefore="Engine make" />
</Item> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.displacement!} addonBefore="Displacement" addonAfter="l" /> <Input value={tuneData.engineCode!} addonBefore="Engine code" />
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.hp!} addonBefore="HP" style={{ width: '100%' }} /> <Input value={tuneData.displacement!} addonBefore="Displacement" addonAfter="l" />
</Item> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.stockHp!} addonBefore="Stock HP" style={{ width: '100%' }} /> <Input value={tuneData.cylindersCount!} addonBefore="Cylinders" style={{ width: '100%' }} />
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.engineCode!} addonBefore="Engine code" /> <Select placeholder="Aspiration" style={{ width: '100%' }} value={tuneData.aspiration}>
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.details.cylindersCount!} addonBefore="No of cylinders" style={{ width: '100%' }} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Select placeholder="Aspiration" style={{ width: '100%' }} value={tuneData.details.aspiration}>
<Select.Option value="na">Naturally aspirated</Select.Option> <Select.Option value="na">Naturally aspirated</Select.Option>
<Select.Option value="turbocharger">Turbocharged</Select.Option> <Select.Option value="turbocharged">Turbocharged</Select.Option>
<Select.Option value="supercharger">Supercharged</Select.Option> <Select.Option value="supercharged">Supercharged</Select.Option>
</Select> </Select>
</Item> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.fuel!} addonBefore="Fuel" /> <Input value={tuneData.compression!} addonBefore="Compression" style={{ width: '100%' }} addonAfter=":1" />
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.injectorsSize!} addonBefore="Injectors size" addonAfter="cc" /> <Input value={tuneData.fuel!} addonBefore="Fuel" />
</Item> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>
<Item> <Item>
<Input value={tuneData.details.coils!} addonBefore="Coils" /> <Input value={tuneData.ignition!} addonBefore="Ignition" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.injectorsSize!} addonBefore="Injectors size" addonAfter="cc" />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.year!} addonBefore="Year" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={tuneData.hp!} addonBefore="HP" style={{ width: '100%' }} />
</Item>
</Col>
<Col {...colProps}>
<Item>
<Input value={tuneData.stockHp!} addonBefore="Stock HP" style={{ width: '100%' }} />
</Item> </Item>
</Col> </Col>
</Row> </Row>
</Form> </Form>
<Divider>README</Divider> <Divider>README</Divider>
<div className="markdown-preview" style={{ height: '100%' }}> <div className="markdown-preview" style={{ height: '100%' }}>
{tuneData.details?.readme && <ReactMarkdown> {tuneData.readme && <ReactMarkdown>
{`${tuneData.details?.readme}`} {`${tuneData.readme}`}
</ReactMarkdown>} </ReactMarkdown>}
</div> </div>
</div> </div >
); );
}; };

View File

@ -147,7 +147,7 @@ const Logs = ({
}; };
}).filter((val) => !!val); }).filter((val) => !!val);
}, [config.datalog, findOutputChannel, isConfigReady]); }, [config?.datalog, findOutputChannel, isConfigReady]);
useEffect(() => { useEffect(() => {
const worker = new MlgParserWorker(); const worker = new MlgParserWorker();
@ -178,7 +178,7 @@ const Logs = ({
store.dispatch({ type: 'logs/load', payload: data.result.records }); store.dispatch({ type: 'logs/load', payload: data.result.records });
break; break;
case 'metrics': case 'metrics':
console.log(`Log parsed in ${data.elapsed}ms`); console.info(`Log parsed in ${data.elapsed}ms`);
setParseElapsed(msToTime(data.elapsed)); setParseElapsed(msToTime(data.elapsed));
setSamplesCount(data.records); setSamplesCount(data.records);
setStep(2); setStep(2);
@ -213,7 +213,7 @@ const Logs = ({
worker.terminate(); worker.terminate();
window.removeEventListener('resize', calculateCanvasSize); window.removeEventListener('resize', calculateCanvasSize);
}; };
}, [calculateCanvasSize, config.datalog, config.outputChannels, loadedLogs, ui.sidebarCollapsed]); }, [calculateCanvasSize, config?.datalog, config?.outputChannels, loadedLogs, ui.sidebarCollapsed]);
return ( return (
<> <>

View File

@ -10,16 +10,20 @@ import Dialog from '../components/Tune/Dialog';
import SideBar from '../components/Tune/SideBar'; import SideBar from '../components/Tune/SideBar';
import { Routes } from '../routes'; import { Routes } from '../routes';
import useConfig from '../hooks/useConfig'; import useConfig from '../hooks/useConfig';
import { AppState } from '../types/state'; import {
AppState,
TuneState,
} from '../types/state';
import Loader from '../components/Loader'; import Loader from '../components/Loader';
const mapStateToProps = (state: AppState) => ({ const mapStateToProps = (state: AppState) => ({
navigation: state.navigation, navigation: state.navigation,
status: state.status, status: state.status,
config: state.config, config: state.config,
tune: state.tune,
}); });
const Tune = ({ config }: { config: ConfigType }) => { const Tune = ({ config, tune }: { config: ConfigType | null, tune: TuneState }) => {
const dialogMatch = useMatch(Routes.TUNE_DIALOG); const dialogMatch = useMatch(Routes.TUNE_DIALOG);
const tuneRootMatch = useMatch(Routes.TUNE_TUNE); const tuneRootMatch = useMatch(Routes.TUNE_TUNE);
// const { storageGetSync } = useBrowserStorage(); // const { storageGetSync } = useBrowserStorage();
@ -31,9 +35,9 @@ const Tune = ({ config }: { config: ConfigType }) => {
const tuneId = tunePathMatch?.params.tuneId; const tuneId = tunePathMatch?.params.tuneId;
useEffect(() => { useEffect(() => {
if (isConfigReady && tuneRootMatch) { if (tune && config && tuneRootMatch) {
const firstCategory = Object.keys(config.menus)[0]; const firstCategory = Object.keys(config!.menus)[0];
const firstDialog = Object.keys(config.menus[firstCategory].subMenus)[0]; const firstDialog = Object.keys(config!.menus[firstCategory].subMenus)[0];
const firstDialogPath = generatePath(Routes.TUNE_DIALOG, { const firstDialogPath = generatePath(Routes.TUNE_DIALOG, {
tuneId, tuneId,
@ -43,9 +47,9 @@ const Tune = ({ config }: { config: ConfigType }) => {
navigate(firstDialogPath, { replace: true }); navigate(firstDialogPath, { replace: true });
} }
}, [navigate, tuneRootMatch, isConfigReady, config.menus, tuneId]); }, [navigate, tuneRootMatch, isConfigReady, config?.menus, tuneId, config, tune, dialogMatch]);
if (!isConfigReady || !dialogMatch) { if (!tune || !config || !dialogMatch) {
return <Loader />; return <Loader />;
} }

View File

@ -13,7 +13,6 @@ import {
Row, Row,
Select, Select,
Space, Space,
Switch,
Tabs, Tabs,
Tooltip, Tooltip,
Typography, Typography,
@ -35,13 +34,11 @@ import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface'; import { UploadFile } from 'antd/lib/upload/interface';
import { import {
generatePath, generatePath,
useMatch,
useNavigate, useNavigate,
} from 'react-router-dom'; } from 'react-router-dom';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { import { nanoid } from 'nanoid';
customAlphabet,
nanoid,
} from 'nanoid';
import { import {
emailNotVerified, emailNotVerified,
restrictedPage, restrictedPage,
@ -52,9 +49,15 @@ import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser'; import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogParser from '../utils/logs/LogParser'; import LogParser from '../utils/logs/LogParser';
import useDb from '../hooks/useDb'; import useDb from '../hooks/useDb';
import useServerStorage from '../hooks/useServerStorage'; import useServerStorage, { ServerFile } from '../hooks/useServerStorage';
import { generateShareUrl } from '../utils/url'; import { buildFullUrl } from '../utils/url';
import Loader from '../components/Loader'; import Loader from '../components/Loader';
import {
requiredTextRules,
requiredRules,
} from '../utils/form';
import { TuneDbDataPartial } from '../types/dbData';
import { aspirationMapper } from '../utils/tune/mappers';
const { Item } = Form; const { Item } = Form;
@ -65,22 +68,13 @@ enum MaxFiles {
CUSTOM_INI_FILES = 1, CUSTOM_INI_FILES = 1,
} }
type Path = string;
interface UploadedFile {
[autoUid: string]: Path;
}
interface UploadFileData {
path: string;
}
interface ValidationResult { interface ValidationResult {
result: boolean; result: boolean;
message: string; message: string;
} }
type ValidateFile = (file: File) => Promise<ValidationResult>; type ValidateFile = (file: File) => Promise<ValidationResult>;
type UploadDone = (fileCreated: ServerFile, file: File) => void;
const rowProps = { gutter: 10 }; const rowProps = { gutter: 10 };
const colProps = { span: 24, sm: 12 }; const colProps = { span: 24, sm: 12 };
@ -88,31 +82,48 @@ const colProps = { span: 24, sm: 12 };
const maxFileSizeMB = 50; const maxFileSizeMB = 50;
const descriptionEditorHeight = 260; const descriptionEditorHeight = 260;
const thisYear = (new Date()).getFullYear(); const thisYear = (new Date()).getFullYear();
const nanoidCustom = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10); const generateTuneId = () => nanoid(10);
const tuneIcon = () => <ToolOutlined />; const tuneIcon = () => <ToolOutlined />;
const logIcon = () => <FundOutlined />; const logIcon = () => <FundOutlined />;
const toothLogIcon = () => <SettingOutlined />; const toothLogIcon = () => <SettingOutlined />;
const iniIcon = () => <FileTextOutlined />; const iniIcon = () => <FileTextOutlined />;
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const tuneParser = new TuneParser();
const UploadPage = () => { const UploadPage = () => {
const routeMatch = useMatch(Routes.UPLOAD_WITH_TUNE_ID);
const [isLoading, setIsLoading] = useState(false);
const [isTuneLoading, setTuneIsLoading] = useState(true);
const [newTuneId, setNewTuneId] = useState<string>(); const [newTuneId, setNewTuneId] = useState<string>();
const [tuneDocumentId, setTuneDocumentId] = useState<string>();
const [isUserAuthorized, setIsUserAuthorized] = useState(false); const [isUserAuthorized, setIsUserAuthorized] = useState(false);
const [shareUrl, setShareUrl] = useState<string>(); const [shareUrl, setShareUrl] = useState<string>();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [tuneFile, setTuneFile] = useState<UploadedFile | null | false>(null); const [isEditMode, setIsEditMode] = useState(false);
const [logFiles, setLogFiles] = useState<UploadedFile>({});
const [toothLogFiles, setToothLogFiles] = useState<UploadedFile>({});
const [customIniFile, setCustomIniFile] = useState<UploadedFile | null>(null);
const hasNavigatorShare = navigator.share !== undefined;
const { currentUser, refreshToken } = useAuth();
const navigate = useNavigate();
const { removeFile, uploadFile, basePathForFile } = useServerStorage();
const { updateData } = useDb();
const requiredRules = [{ required: true, message: 'This field is required!' }];
const [readme, setReadme] = useState('# My Tune\n\ndescription'); const [readme, setReadme] = useState('# My Tune\n\ndescription');
const [initialValues, setInitialValues] = useState<TuneDbDataPartial>({
readme,
});
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
const [defaultToothLogFilesList, setDefaultToothLogFilesList] = useState<UploadFile[]>([]);
const [defaultCustomIniFileList, setDefaultCustomIniFileList] = useState<UploadFile[]>([]);
const [tuneFileId, setTuneFileId] = useState<string | null>(null);
const [logFileIds, setLogFileIds] = useState<Map<string, string>>(new Map());
const [toothLogFileIds, setToothLogFileIds] = useState<Map<string, string>>(new Map());
const [customIniFileId, setCustomIniFileId] = useState<string | null>(null);
const hasNavigatorShare = navigator.share !== undefined;
const { currentUser } = useAuth();
const navigate = useNavigate();
const { removeFile, uploadFile, getFile } = useServerStorage();
const { createTune, getBucketId, updateTune, getTune } = useDb();
const noop = () => { }; const noop = () => { };
@ -130,29 +141,51 @@ const UploadPage = () => {
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message }); const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
const publish = async (values: any) => { const publishTune = async (values: any) => {
/* eslint-disable prefer-destructuring */
const vehicleName = values.vehicleName.trim();
const engineMake = values.engineMake.trim();
const engineCode = values.engineCode.trim();
const displacement = values.displacement;
const cylindersCount = values.cylindersCount;
const aspiration = values.aspiration.trim();
const compression = values.compression || null;
const fuel = values.fuel?.trim() || null;
const ignition = values.ignition?.trim() || null;
const injectorsSize = values.injectorsSize || null;
const year = values.year || null;
const hp = values.hp || null;
const stockHp = values.stockHp || null;
/* eslint-enable prefer-destructuring */
setIsLoading(true); setIsLoading(true);
await updateData(newTuneId!, { await updateTune(tuneDocumentId!, {
id: newTuneId!, vehicleName,
userUid: currentUser!.uid, engineMake,
updatedAt: new Date(), engineCode,
isPublished: true, displacement,
isListed: values.isListed, cylindersCount,
details: { aspiration,
readme: readme || null, compression,
make: values.make || null, fuel,
model: values.model || null, ignition,
displacement: values.displacement || null, injectorsSize,
year: values.year || null, year,
hp: values.hp || null, hp,
stockHp: values.stockHp || null, stockHp,
engineCode: values.engineCode || null, readme: readme?.trim(),
cylindersCount: values.cylindersCount || null, textSearch: [
aspiration: values.aspiration || null, vehicleName,
fuel: values.fuel || null, engineMake,
injectorsSize: values.injectorsSize || null, engineCode,
coils: values.coils || null, `${displacement}l`,
}, aspirationMapper[aspiration] || null,
fuel,
ignition,
year,
].filter((field) => field !== null && `${field}`.length > 1)
.join(' ')
.replace(/[^A-z\d ]/g, ''),
}); });
setIsLoading(false); setIsLoading(false);
setIsPublished(true); setIsPublished(true);
@ -163,8 +196,14 @@ const UploadPage = () => {
message: `File should not be larger than ${maxFileSizeMB}MB!`, message: `File should not be larger than ${maxFileSizeMB}MB!`,
}); });
const upload = async (path: string, options: UploadRequestOption, done: Function, validate: ValidateFile) => { const navigateToNewTuneId = useCallback(() => {
const { onError, onSuccess, onProgress, file } = options; navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId: generateTuneId(),
}), { replace: true });
}, [navigate]);
const upload = async (options: UploadRequestOption, done: UploadDone, validate: ValidateFile) => {
const { onError, onSuccess, file } = options;
const validation = await validate(file as File); const validation = await validate(file as File);
if (!validation.result) { if (!validation.result) {
@ -172,6 +211,7 @@ const UploadPage = () => {
const errorMessage = validation.message; const errorMessage = validation.message;
notification.error({ message: errorName, description: errorMessage }); notification.error({ message: errorName, description: errorMessage });
onError!({ name: errorName, message: errorMessage }); onError!({ name: errorName, message: errorMessage });
return false; return false;
} }
@ -179,96 +219,68 @@ const UploadPage = () => {
const pako = await import('pako'); const pako = await import('pako');
const buffer = await (file as File).arrayBuffer(); const buffer = await (file as File).arrayBuffer();
const compressed = pako.deflate(new Uint8Array(buffer)); const compressed = pako.deflate(new Uint8Array(buffer));
const uploadTask = uploadFile(path, file as File, compressed); const bucketId = await getBucketId(currentUser!.$id);
const fileCreated: ServerFile = await uploadFile(currentUser!.$id, bucketId, new File([compressed], (file as File).name));
uploadTask.on( done(fileCreated, file as File);
'state_changed', onSuccess!(null);
(snap) => onProgress!({ percent: (snap.bytesTransferred / snap.totalBytes) * 100 }),
(err) => onError!(err),
() => {
onSuccess!(file);
if (done) done();
},
);
} catch (error) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
console.error('Upload error:', error); console.error('Upload error:', error);
notification.error({ message: 'Upload error', description: (error as Error).message }); notification.error({ message: 'Upload error', description: (error as Error).message });
onError!(error as Error); onError!(error as Error);
return false;
} }
return true; return true;
}; };
const tuneFileData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `tune/${nanoid()}.msq.gz`),
});
const logFileData = (file: UploadFile) => {
const { name } = file;
const extension = name.split('.').pop();
return {
path: basePathForFile(currentUser!.uid, newTuneId!, `logs/${nanoid()}.${extension}.gz`),
};
};
const toothLogFilesData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `tooth-logs/${nanoid()}.csv.gz`),
});
const customIniFileData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `ini/${nanoid()}.ini.gz`),
});
const uploadTune = async (options: UploadRequestOption) => { const uploadTune = async (options: UploadRequestOption) => {
setShareUrl(generateShareUrl(newTuneId!)); upload(options, async (fileCreated: ServerFile, file: File) => {
const { signature } = tuneParser.parse(await file.arrayBuffer()).getTune().details;
const { path } = (options.data as unknown as UploadFileData); if (tuneDocumentId) {
const tune: UploadedFile = {}; await updateTune(tuneDocumentId, {
tune[(options.file as UploadFile).uid] = path; signature,
tuneFileId: fileCreated.$id,
});
} else {
const document = await createTune({
userId: currentUser!.$id,
tuneId: newTuneId!,
signature,
tuneFileId: fileCreated.$id,
vehicleName: '',
displacement: 0,
cylindersCount: 0,
engineMake: '',
engineCode: '',
aspiration: 'na',
readme: '',
});
setTuneDocumentId(document.$id);
}
upload(path, options, () => { setTuneFileId(fileCreated.$id);
// this is `create` for firebase
// initialize data
updateData(newTuneId!, {
id: newTuneId!,
userUid: currentUser!.uid,
createdAt: new Date(),
updatedAt: new Date(),
isPublished: false,
isListed: true,
details: {},
tuneFile: path,
});
}, async (file) => { }, async (file) => {
const { result, message } = await validateSize(file); const { result, message } = await validateSize(file);
if (!result) { if (!result) {
setTuneFile(false);
return { result, message }; return { result, message };
} }
const valid = (new TuneParser()).parse(await file.arrayBuffer()).isValid();
if (!valid) {
setTuneFile(false);
} else {
setTuneFile(tune);
}
return { return {
result: valid, result: tuneParser.parse(await file.arrayBuffer()).isValid(),
message: 'Tune file is not valid!', message: 'Tune file is not valid!',
}; };
}); });
}; };
const uploadLogs = async (options: UploadRequestOption) => { const uploadLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData); upload(options, async (fileCreated) => {
const tune: UploadedFile = {}; const newValues = new Map(logFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
const uuid = (options.file as UploadFile).uid; await updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
tune[uuid] = path; setLogFileIds(newValues);
const newValues = { ...logFiles, ...tune };
upload(path, options, () => {
updateData(newTuneId!, { logFiles: Object.values(newValues) });
}, async (file) => { }, async (file) => {
const { result, message } = await validateSize(file); const { result, message } = await validateSize(file);
if (!result) { if (!result) {
@ -292,10 +304,6 @@ const UploadPage = () => {
break; break;
} }
if (valid) {
setLogFiles(newValues);
}
return { return {
result: valid, result: valid,
message: 'Log file is empty or not valid!', message: 'Log file is empty or not valid!',
@ -304,12 +312,10 @@ const UploadPage = () => {
}; };
const uploadToothLogs = async (options: UploadRequestOption) => { const uploadToothLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData); upload(options, async (fileCreated) => {
const tune: UploadedFile = {}; const newValues = new Map(toothLogFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
tune[(options.file as UploadFile).uid] = path; await updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
const newValues = { ...toothLogFiles, ...tune }; setToothLogFileIds(newValues);
upload(path, options, () => {
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
}, async (file) => { }, async (file) => {
const { result, message } = await validateSize(file); const { result, message } = await validateSize(file);
if (!result) { if (!result) {
@ -317,25 +323,18 @@ const UploadPage = () => {
} }
const parser = new TriggerLogsParser(await file.arrayBuffer()); const parser = new TriggerLogsParser(await file.arrayBuffer());
const valid = parser.isComposite() || parser.isTooth();
if (valid) {
setToothLogFiles(newValues);
}
return { return {
result: valid, result: parser.isComposite() || parser.isTooth(),
message: 'Tooth logs file is empty or not valid!', message: 'Tooth logs file is empty or not valid!',
}; };
}); });
}; };
const uploadCustomIni = async (options: UploadRequestOption) => { const uploadCustomIni = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData); upload(options, async (fileCreated) => {
const tune: UploadedFile = {}; await updateTune(tuneDocumentId!, { customIniFileId: fileCreated.$id });
tune[(options.file as UploadFile).uid] = path; setCustomIniFileId(fileCreated.$id);
upload(path, options, () => {
updateData(newTuneId!, { customIniFile: path });
}, async (file) => { }, async (file) => {
const { result, message } = await validateSize(file); const { result, message } = await validateSize(file);
if (!result) { if (!result) {
@ -352,10 +351,6 @@ const UploadPage = () => {
validationMessage = (error as Error).message; validationMessage = (error as Error).message;
} }
if (valid) {
setCustomIniFile(tune);
}
return { return {
result: valid, result: valid,
message: validationMessage, message: validationMessage,
@ -363,44 +358,99 @@ const UploadPage = () => {
}); });
}; };
const removeTuneFile = async (file: UploadFile) => { const removeFileFromStorage = async (fileId: string) => {
if (tuneFile) { await removeFile(await getBucketId(currentUser!.$id), fileId);
removeFile(tuneFile[file.uid]); };
}
setTuneFile(null); const removeTuneFile = async () => {
updateData(newTuneId!, { tuneFile: null }); await removeFileFromStorage(tuneFileId!);
await updateTune(tuneDocumentId!, { tuneFileId: null });
setTuneFileId(null);
}; };
const removeLogFile = async (file: UploadFile) => { const removeLogFile = async (file: UploadFile) => {
const { uid } = file; await removeFileFromStorage(logFileIds.get(file.uid)!);
if (logFiles[file.uid]) { logFileIds.delete(file.uid);
removeFile(logFiles[file.uid]); const newValues = new Map(logFileIds);
} setLogFileIds(newValues);
const newValues = { ...logFiles }; updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
delete newValues[uid];
setLogFiles(newValues);
updateData(newTuneId!, { logFiles: Object.values(newValues) });
}; };
const removeToothLogFile = async (file: UploadFile) => { const removeToothLogFile = async (file: UploadFile) => {
const { uid } = file; await removeFileFromStorage(toothLogFileIds.get(file.uid)!);
if (toothLogFiles[file.uid]) { toothLogFileIds.delete(file.uid);
removeFile(toothLogFiles[file.uid]); const newValues = new Map(toothLogFileIds);
} setToothLogFileIds(newValues);
const newValues = { ...toothLogFiles }; updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
delete newValues[uid];
setToothLogFiles(newValues);
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
}; };
const removeCustomIniFile = async (file: UploadFile) => { const removeCustomIniFile = async (file: UploadFile) => {
if (customIniFile) { await removeFileFromStorage(customIniFileId!);
removeFile(customIniFile![file.uid]); await updateTune(tuneDocumentId!, { customIniFileId: null });
} setCustomIniFileId(null);
setCustomIniFile(null);
updateData(newTuneId!, { customIniFile: null });
}; };
const loadExistingTune = useCallback(async (currentTuneId: string) => {
setNewTuneId(currentTuneId);
console.info('Using tuneId:', currentTuneId);
const existingTune = await getTune(currentTuneId);
if (existingTune) {
// this is someone elses tune
if (existingTune.userId !== currentUser?.$id) {
navigateToNewTuneId();
return;
}
setInitialValues(existingTune);
setIsEditMode(true);
setTuneDocumentId(existingTune.$id);
if (existingTune.tuneFileId) {
const file = await getFile(existingTune.tuneFileId, await getBucketId(currentUser!.$id));
setTuneFileId(existingTune.tuneFileId);
setDefaultTuneFileList([{
uid: file.$id,
name: file.name,
status: 'done',
}]);
}
if (existingTune.customIniFileId) {
const file = await getFile(existingTune.customIniFileId, await getBucketId(currentUser!.$id));
setCustomIniFileId(existingTune.customIniFileId);
setDefaultCustomIniFileList([{
uid: file.$id,
name: file.name,
status: 'done',
}]);
}
existingTune.logFileIds?.forEach(async (fileId: string) => {
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
setLogFileIds((prev) => new Map(prev).set(fileId, fileId));
setDefaultLogFilesList((prev) => [...prev, {
uid: file.$id,
name: file.name,
status: 'done',
}]);
});
existingTune.toothLogFileIds?.forEach(async (fileId: string) => {
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
setToothLogFileIds((prev) => new Map(prev).set(fileId, fileId));
setDefaultToothLogFilesList((prev) => [...prev, {
uid: file.$id,
name: file.name,
status: 'done',
}]);
});
}
setTuneIsLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prepareData = useCallback(async () => { const prepareData = useCallback(async () => {
if (!currentUser) { if (!currentUser) {
restrictedPage(); restrictedPage();
@ -410,8 +460,7 @@ const UploadPage = () => {
} }
try { try {
await refreshToken(); if (!currentUser.emailVerification) {
if (!currentUser.emailVerified) {
emailNotVerified(); emailNotVerified();
navigate(Routes.LOGIN); navigate(Routes.LOGIN);
@ -424,14 +473,18 @@ const UploadPage = () => {
genericError(error as Error); genericError(error as Error);
} }
const tuneId = nanoidCustom(); const currentTuneId = routeMatch?.params.tuneId;
setNewTuneId(tuneId); if (currentTuneId) {
console.log('New tuneId:', tuneId); loadExistingTune(currentTuneId);
}, [currentUser, navigate, refreshToken]); setShareUrl(buildFullUrl([tunePath(currentTuneId)]));
} else {
navigateToNewTuneId();
}
}, [currentUser, loadExistingTune, navigate, navigateToNewTuneId, routeMatch?.params.tuneId]);
useEffect(() => { useEffect(() => {
prepareData(); prepareData();
}, [currentUser, prepareData, refreshToken]); }, [currentUser, prepareData]);
const uploadButton = ( const uploadButton = (
<Space direction="vertical"> <Space direction="vertical">
@ -467,7 +520,7 @@ const UploadPage = () => {
loading={isLoading} loading={isLoading}
htmlType="submit" htmlType="submit"
> >
Publish {isEditMode ? 'Update' : 'Publish'}
</Button> : <Button </Button> : <Button
type="primary" type="primary"
block block
@ -486,78 +539,85 @@ const UploadPage = () => {
<Space>Details</Space> <Space>Details</Space>
</Divider> </Divider>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col span={24} sm={24}>
<Item name="make" rules={requiredRules}> <Item name="vehicleName" rules={requiredTextRules}>
<Input addonBefore="Make"/> <Input addonBefore="Vehicle name" />
</Item>
</Col>
<Col {...colProps}>
<Item name="model" rules={requiredRules}>
<Input addonBefore="Model"/>
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="year" rules={requiredRules}> <Item name="engineMake" rules={requiredTextRules}>
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} /> <Input addonBefore="Engine make" />
</Item> </Item>
</Col> </Col>
<Col {...colProps}>
<Item name="engineCode" rules={requiredTextRules}>
<Input addonBefore="Engine code" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="displacement" rules={requiredRules}> <Item name="displacement" rules={requiredRules}>
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} /> <InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
</Item> </Item>
</Col> </Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="hp"> <Item name="cylindersCount" rules={requiredRules}>
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} /> <InputNumber addonBefore="Cylinders" style={{ width: '100%' }} min={0} max={16} />
</Item>
</Col>
<Col {...colProps}>
<Item name="stockHp">
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} />
</Item> </Item>
</Col> </Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="engineCode"> <Item name="aspiration" rules={requiredTextRules}>
<Input addonBefore="Engine code"/>
</Item>
</Col>
<Col {...colProps}>
<Item name="cylindersCount">
<InputNumber addonBefore="No of cylinders" style={{ width: '100%' }} min={0} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="aspiration">
<Select placeholder="Aspiration" style={{ width: '100%' }}> <Select placeholder="Aspiration" style={{ width: '100%' }}>
<Select.Option value="na">Naturally aspirated</Select.Option> <Select.Option value="na">Naturally aspirated</Select.Option>
<Select.Option value="turbocharger">Turbocharged</Select.Option> <Select.Option value="turbocharged">Turbocharged</Select.Option>
<Select.Option value="supercharger">Supercharged</Select.Option> <Select.Option value="supercharged">Supercharged</Select.Option>
</Select> </Select>
</Item> </Item>
</Col> </Col>
<Col {...colProps}>
<Item name="compression">
<InputNumber addonBefore="Compression" style={{ width: '100%' }} min={0} max={100} step={0.1} addonAfter=":1" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="fuel"> <Item name="fuel">
<Input addonBefore="Fuel" /> <Input addonBefore="Fuel" />
</Item> </Item>
</Col> </Col>
<Col {...colProps}>
<Item name="ignition">
<Input addonBefore="Ignition" />
</Item>
</Col>
</Row> </Row>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item name="injectorsSize"> <Item name="injectorsSize">
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} /> <InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} max={100_000} />
</Item> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>
<Item name="coils"> <Item name="year">
<Input addonBefore="Coils" /> <InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="hp">
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} max={100_000} />
</Item>
</Col>
<Col {...colProps}>
<Item name="stockHp">
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} max={100_000} />
</Item> </Item>
</Col> </Col>
</Row> </Row>
@ -587,12 +647,6 @@ const UploadPage = () => {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
<Divider>
Visibility
</Divider>
<Item name="isListed" label="Listed:" valuePropName="checked">
<Switch />
</Item>
</> </>
); );
@ -605,18 +659,19 @@ const UploadPage = () => {
</Space> </Space>
</Divider> </Divider>
<Upload <Upload
key={defaultLogFilesList.map((file) => file.uid).join('-') || 'logs'}
listType="picture-card" listType="picture-card"
customRequest={uploadLogs} customRequest={uploadLogs}
data={logFileData}
onRemove={removeLogFile} onRemove={removeLogFile}
iconRender={logIcon} iconRender={logIcon}
multiple multiple
maxCount={MaxFiles.LOG_FILES} maxCount={MaxFiles.LOG_FILES}
disabled={isPublished} disabled={isPublished}
onPreview={noop} onPreview={noop}
defaultFileList={defaultLogFilesList}
accept=".mlg,.csv,.msl" accept=".mlg,.csv,.msl"
> >
{Object.keys(logFiles).length < MaxFiles.LOG_FILES && uploadButton} {logFileIds.size < MaxFiles.LOG_FILES && uploadButton}
</Upload> </Upload>
<Divider> <Divider>
<Space> <Space>
@ -625,17 +680,18 @@ const UploadPage = () => {
</Space> </Space>
</Divider> </Divider>
<Upload <Upload
key={defaultToothLogFilesList.map((file) => file.uid).join('-') || 'toothLogs'}
listType="picture-card" listType="picture-card"
customRequest={uploadToothLogs} customRequest={uploadToothLogs}
data={toothLogFilesData}
onRemove={removeToothLogFile} onRemove={removeToothLogFile}
iconRender={toothLogIcon} iconRender={toothLogIcon}
multiple multiple
maxCount={MaxFiles.TOOTH_LOG_FILES} maxCount={MaxFiles.TOOTH_LOG_FILES}
onPreview={noop} onPreview={noop}
defaultFileList={defaultToothLogFilesList}
accept=".csv" accept=".csv"
> >
{Object.keys(toothLogFiles).length < MaxFiles.TOOTH_LOG_FILES && uploadButton} {toothLogFileIds.size < MaxFiles.TOOTH_LOG_FILES && uploadButton}
</Upload> </Upload>
<Divider> <Divider>
<Space> <Space>
@ -644,23 +700,24 @@ const UploadPage = () => {
</Space> </Space>
</Divider> </Divider>
<Upload <Upload
key={defaultCustomIniFileList[0]?.uid || 'customIni'}
listType="picture-card" listType="picture-card"
customRequest={uploadCustomIni} customRequest={uploadCustomIni}
data={customIniFileData}
onRemove={removeCustomIniFile} onRemove={removeCustomIniFile}
iconRender={iniIcon} iconRender={iniIcon}
disabled={isPublished} disabled={isPublished}
onPreview={noop} onPreview={noop}
defaultFileList={defaultCustomIniFileList}
accept=".ini" accept=".ini"
> >
{!customIniFile && uploadButton} {!customIniFileId && uploadButton}
</Upload> </Upload>
{detailsSection} {detailsSection}
{shareUrl && tuneFile && shareSection} {shareUrl && tuneFileId && shareSection}
</> </>
); );
if (!isUserAuthorized) { if (!isUserAuthorized || isTuneLoading) {
return <Loader />; return <Loader />;
} }
@ -674,13 +731,7 @@ const UploadPage = () => {
return ( return (
<div className="small-container"> <div className="small-container">
<Form <Form onFinish={publishTune} initialValues={initialValues}>
onFinish={publish}
initialValues={{
readme: '# My Tune\n\ndescription',
isListed: true,
}}
>
<Divider> <Divider>
<Space> <Space>
Upload Tune Upload Tune
@ -688,18 +739,19 @@ const UploadPage = () => {
</Space> </Space>
</Divider> </Divider>
<Upload <Upload
key={defaultTuneFileList[0]?.uid || 'tuneFile'}
listType="picture-card" listType="picture-card"
customRequest={uploadTune} customRequest={uploadTune}
data={tuneFileData}
onRemove={removeTuneFile} onRemove={removeTuneFile}
iconRender={tuneIcon} iconRender={tuneIcon}
disabled={isPublished} disabled={isPublished}
onPreview={noop} onPreview={noop}
defaultFileList={defaultTuneFileList}
accept=".msq" accept=".msq"
> >
{tuneFile === null && uploadButton} {tuneFileId === null && uploadButton}
</Upload> </Upload>
{tuneFile && optionalSection} {(tuneFileId || defaultTuneFileList.length > 0) && optionalSection}
</Form> </Form>
</div> </div>
); );

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import {
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loader from '../../components/Loader';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import {
emailVerificationFailed,
emailVerificationSuccess,
} from './notifications';
const EmailVerification = () => {
const { confirmEmailVerification } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
useEffect(() => {
if (userId && secret) {
confirmEmailVerification(userId, secret)
.then(() => emailVerificationSuccess())
.catch((error) => {
console.error(error);
emailVerificationFailed(error);
});
} else {
emailVerificationFailed(new Error('Invalid URL'));
}
navigate(Routes.HUB);
});
return <Loader />;
};
export default EmailVerification;

View File

@ -12,8 +12,10 @@ import {
import { import {
MailOutlined, MailOutlined,
LockOutlined, LockOutlined,
UnlockOutlined,
GoogleOutlined, GoogleOutlined,
GithubOutlined, GithubOutlined,
FacebookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
Link, Link,
@ -26,90 +28,166 @@ import {
emailNotVerified, emailNotVerified,
logInFailed, logInFailed,
logInSuccessful, logInSuccessful,
magicLinkSent,
} from './notifications'; } from './notifications';
import {
emailRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form; const { Item } = Form;
const Login = () => { const Login = () => {
const [form] = Form.useForm(); const [formMagicLink] = Form.useForm();
const [formEmail] = Form.useForm();
const [isEmailLoading, setIsEmailLoading] = useState(false); const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false); const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false); const [isGithubLoading, setIsGithubLoading] = useState(false);
const { login, googleAuth, githubAuth } = useAuth(); const [isFacebookLoading, setIsFacebookLoading] = useState(false);
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
const { login, googleAuth, githubAuth, facebookAuth, sendMagicLink } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading; const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]); const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
const googleLogin = useCallback(async () => { const googleLogin = useCallback(async () => {
setIsGoogleLoading(true); setIsGoogleLoading(true);
try { try {
await googleAuth(); await googleAuth();
logInSuccessful();
redirectAfterLogin();
} catch (error) { } catch (error) {
logInFailed(error as Error); logInFailed(error as Error);
setIsGoogleLoading(false);
} }
}, [googleAuth, redirectAfterLogin]); }, [googleAuth]);
const githubLogin = useCallback(async () => { const githubLogin = useCallback(async () => {
setIsGithubLoading(true); setIsGithubLoading(true);
try { try {
await githubAuth(); await githubAuth();
logInSuccessful();
redirectAfterLogin();
} catch (error) { } catch (error) {
logInFailed(error as Error); logInFailed(error as Error);
setIsGithubLoading(false);
} }
}, [githubAuth, redirectAfterLogin]); }, [githubAuth]);
const emailLogin = async ({ email, password }: { form: any, email: string, password: string }) => { const facebookLogin = async () => {
setIsFacebookLoading(true);
try {
await facebookAuth();
} catch (error) {
logInFailed(error as Error);
}
};
const emailLogin = async ({ email, password }: { email: string, password: string }) => {
setIsEmailLoading(true); setIsEmailLoading(true);
try { try {
const userCredentials = await login(email, password); const user = await login(email, password);
logInSuccessful(); logInSuccessful();
if (!user.emailVerification) {
if (!userCredentials.user.emailVerified) {
emailNotVerified(); emailNotVerified();
} }
if (!user.name) {
navigate(Routes.PROFILE);
}
redirectAfterLogin(); redirectAfterLogin();
} catch (error) { } catch (error) {
form.resetFields();
console.warn(error); console.warn(error);
logInFailed(error as Error); logInFailed(error as Error);
formMagicLink.resetFields();
formEmail.resetFields();
setIsEmailLoading(false); setIsEmailLoading(false);
} }
}; };
const magicLinkLogin = async ({ email }: { email: string }) => {
setIsMagicLinkLoading(true);
try {
await sendMagicLink(email);
magicLinkSent();
} catch (error) {
logInFailed(error as Error);
} finally {
setIsMagicLinkLoading(false);
formMagicLink.resetFields();
formEmail.resetFields();
}
};
return ( return (
<div className="small-container"> <div className="auth-container">
<Divider>Log In using email</Divider> <Divider>Log In</Divider>
<Form <Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
onFinish={emailLogin} <Button
validateMessages={validateMessages} loading={isGoogleLoading}
autoComplete="off" onClick={googleLogin}
form={form} disabled={isAnythingLoading}
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
> >
<GoogleOutlined />Google
</Button>
<Button
loading={isGithubLoading}
onClick={githubLogin}
disabled={isAnythingLoading}
>
<GithubOutlined />GitHub
</Button>
<Button
loading={isFacebookLoading}
onClick={facebookLogin}
disabled={isAnythingLoading}
>
<FacebookOutlined />Facebook
</Button>
</Space>
<Divider />
<Form
onFinish={magicLinkLogin}
validateMessages={validateMessages}
form={formMagicLink}
>
<Item name="email" rules={emailRules} hasFeedback>
<Input <Input
prefix={<MailOutlined />} prefix={<MailOutlined />}
placeholder="Email" placeholder="Email"
id="email-magic-link"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isMagicLinkLoading}
disabled={isAnythingLoading}
icon={<MailOutlined />}
>
Send me a Magic Link
</Button>
</Item>
</Form>
<Form
onFinish={emailLogin}
validateMessages={validateMessages}
form={formEmail}
>
<Divider />
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
placeholder="Email"
autoComplete="email"
disabled={isAnythingLoading} disabled={isAnythingLoading}
/> />
</Item> </Item>
<Item <Item
name="password" name="password"
rules={[{ required: true }]} rules={requiredRules}
hasFeedback hasFeedback
> >
<Input.Password <Input.Password
placeholder="Password" placeholder="Password"
autoComplete="current-password"
prefix={<LockOutlined />} prefix={<LockOutlined />}
disabled={isAnythingLoading} disabled={isAnythingLoading}
/> />
@ -121,39 +199,18 @@ const Login = () => {
style={{ width: '100%' }} style={{ width: '100%' }}
loading={isEmailLoading} loading={isEmailLoading}
disabled={isAnythingLoading} disabled={isAnythingLoading}
icon={<UnlockOutlined />}
> >
Log In Log in using password
</Button> </Button>
</Item> </Item>
</Form> <Link to={Routes.SIGN_UP}>
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}> Sign Up
<Item> </Link>
<Button <Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
loading={isGoogleLoading}
onClick={googleLogin}
disabled={isAnythingLoading}
>
<GoogleOutlined />Google
</Button>
</Item>
<Item>
<Button
loading={isGithubLoading}
onClick={githubLogin}
disabled={isAnythingLoading}
>
<GithubOutlined />GitHub
</Button>
</Item>
</Space>
<Button type="link">
<Link to={Routes.SIGN_UP}>Sign Up</Link>
</Button>
<Button type="link" style={{ float: 'right' }}>
<Link to={Routes.RESET_PASSWORD}>
Forgot password? Forgot password?
</Link> </Link>
</Button> </Form>
</div> </div>
); );
}; };

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import {
useNavigate,
useSearchParams,
} from 'react-router-dom';
import Loader from '../../components/Loader';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import {
logInSuccessful,
magicLinkConfirmationFailed,
} from './notifications';
const MagicLinkConfirmation = () => {
const { confirmMagicLink } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
useEffect(() => {
if (userId && secret) {
confirmMagicLink(userId, secret)
.then(() => logInSuccessful())
.catch((error) => {
console.error(error);
magicLinkConfirmationFailed(error);
});
} else {
magicLinkConfirmationFailed(new Error('Invalid URL'));
}
navigate(Routes.HUB);
});
return <Loader />;
};
export default MagicLinkConfirmation;

View File

@ -1,57 +1,261 @@
import { useEffect } from 'react'; import {
useCallback,
useEffect,
useState,
} from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Form, Form,
Input, Input,
Button, Button,
Divider, Divider,
Alert,
Space,
List,
} from 'antd'; } from 'antd';
import { UserOutlined } from '@ant-design/icons'; import {
UserOutlined,
MailOutlined,
LockOutlined,
} from '@ant-design/icons';
import validateMessages from './validateMessages'; import validateMessages from './validateMessages';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { restrictedPage } from './notifications'; import {
restrictedPage,
sendingEmailVerificationFailed,
emailVerificationSent,
profileUpdateSuccess,
profileUpdateFailed,
passwordUpdateSuccess,
passwordUpdateFailed,
} from './notifications';
import { Routes } from '../../routes'; import { Routes } from '../../routes';
import {
passwordRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form; const { Item } = Form;
const MAX_LIST_SIZE = 10;
const parseLogEvent = (raw: string) => {
const split = raw.split('.');
return [split[0], split[2], split[4]].join(' ');
};
const Profile = () => { const Profile = () => {
const { currentUser } = useAuth(); const [formProfile] = Form.useForm();
const [formPassword] = Form.useForm();
const {
currentUser,
sendEmailVerification,
updateUsername,
updatePassword,
getSessions,
getLogs,
} = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [isVerificationSent, setIsVerificationSent] = useState(false);
const [isSendingVerification, setIsSendingVerification] = useState(false);
const [isProfileLoading, setIsProfileLoading] = useState(false);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [sessions, setSessions] = useState<string[]>([]);
const [logs, setLogs] = useState<string[]>([]);
const resendEmailVerification = async () => {
setIsSendingVerification(true);
setIsVerificationSent(true);
try {
await sendEmailVerification();
emailVerificationSent();
} catch (error) {
sendingEmailVerificationFailed(error as Error);
setIsVerificationSent(false);
} finally {
setIsSendingVerification(false);
}
};
const fetchLogs = useCallback(async () => getLogs()
.then((list) => setLogs(list.logs.slice(0, MAX_LIST_SIZE).map((log) => [
new Date(log.time * 1000).toLocaleString(),
parseLogEvent(log.event),
log.clientName,
log.clientEngineVersion,
log.osName,
log.deviceName,
log.countryName,
log.ip,
].join(' | ')))), [getLogs]);
const onUpdateProfile = async ({ username }: { username: string }) => {
setIsProfileLoading(true);
try {
await updateUsername(username);
profileUpdateSuccess();
fetchLogs();
} catch (error) {
profileUpdateFailed(error as Error);
} finally {
setIsProfileLoading(false);
}
};
const onUpdatePassword = async ({ password, oldPassword }: { password: string, oldPassword: string }) => {
setIsPasswordLoading(true);
try {
await updatePassword(password, oldPassword);
passwordUpdateSuccess();
fetchLogs();
formPassword.resetFields();
} catch (error) {
passwordUpdateFailed(error as Error);
} finally {
setIsPasswordLoading(false);
}
};
useEffect(() => { useEffect(() => {
if (!currentUser) { if (currentUser) {
restrictedPage(); getSessions()
navigate(Routes.LOGIN); .then((list) => setSessions(list.sessions.slice(0, MAX_LIST_SIZE).map((ses) => [
ses.clientName,
ses.osName,
ses.deviceName,
ses.countryName,
ses.ip,
].join(' | '))));
fetchLogs();
return;
} }
}, [currentUser, navigate]);
restrictedPage();
navigate(Routes.LOGIN);
}, [currentUser, fetchLogs, getLogs, getSessions, navigate]);
return ( return (
<div className="small-container"> <>
<Divider>Your Profile</Divider> <div className="auth-container">
<Form {!currentUser?.emailVerification && (<>
validateMessages={validateMessages} <Divider>Email verification</Divider>
form={form} <Space direction="vertical" style={{ width: '100%' }} size="large">
autoComplete="off" <Alert message="Your email address is not verified!" type="error" showIcon />
> <Button
<Item type="primary"
name="username" style={{ width: '100%' }}
rules={[{ required: true }]} icon={<MailOutlined />}
hasFeedback disabled={isVerificationSent}
loading={isSendingVerification}
onClick={resendEmailVerification}
>
Resend verification
</Button>
</Space>
</>)}
<Divider>Your Profile</Divider>
<Form
validateMessages={validateMessages}
form={formProfile}
onFinish={onUpdateProfile}
fields={[
{
name: 'username',
value: currentUser?.name,
},
{
name: 'email',
value: currentUser?.email,
},
]}
> >
<Input prefix={<UserOutlined />} placeholder="Username" /> <Item
</Item> name="username"
<Item> rules={requiredRules}
<Button hasFeedback
type="primary"
htmlType="submit"
style={{ width: '100%' }}
> >
Save <Input
</Button> prefix={<UserOutlined />}
</Item> placeholder="Username"
</Form> autoComplete="name"
</div> />
</Item>
<Item name="email">
<Input prefix={<MailOutlined />} placeholder="Email" disabled />
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
icon={<UserOutlined />}
loading={isProfileLoading}
>
Update
</Button>
</Item>
</Form>
<Divider>Password</Divider>
<Form
validateMessages={validateMessages}
form={formPassword}
onFinish={onUpdatePassword}
>
<Item
name="oldPassword"
rules={requiredRules}
hasFeedback
>
<Input.Password
placeholder="Old password"
autoComplete="current-password"
prefix={<LockOutlined />}
/>
</Item>
<Item
name="password"
rules={passwordRules}
hasFeedback
>
<Input.Password
placeholder="New password"
autoComplete="new-password"
prefix={<LockOutlined />}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
icon={<LockOutlined />}
loading={isPasswordLoading}
>
Change
</Button>
</Item>
</Form>
</div>
<div className="large-container">
<Divider>Active sessions</Divider>
<List
size="small"
bordered
dataSource={sessions}
renderItem={item => <List.Item>{item}</List.Item>}
loading={sessions.length === 0}
/>
<Divider>Audit logs</Divider>
<List
size="small"
bordered
dataSource={logs}
renderItem={item => <List.Item>{item}</List.Item>}
loading={logs.length === 0}
/>
</div>
</>
); );
}; };

View File

@ -17,19 +17,20 @@ import {
resetFailed, resetFailed,
resetSuccessful, resetSuccessful,
} from './notifications'; } from './notifications';
import { emailRules } from '../../utils/form';
const { Item } = Form; const { Item } = Form;
const ResetPassword = () => { const ResetPassword = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { resetPassword } = useAuth(); const { initResetPassword } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const onFinish = async ({ email }: { form: any, email: string }) => { const onFinish = async ({ email }: { form: any, email: string }) => {
setIsLoading(true); setIsLoading(true);
try { try {
await resetPassword(email); await initResetPassword(email);
resetSuccessful(); resetSuccessful();
navigate(Routes.LOGIN); navigate(Routes.LOGIN);
} catch (error) { } catch (error) {
@ -41,23 +42,23 @@ const ResetPassword = () => {
}; };
return ( return (
<div className="small-container"> <div className="auth-container">
<Divider>Reset password</Divider> <Divider>Reset password</Divider>
<Form <Form
initialValues={{ remember: true }} initialValues={{ remember: true }}
onFinish={onFinish} onFinish={onFinish}
validateMessages={validateMessages} validateMessages={validateMessages}
autoComplete="off"
form={form} form={form}
> >
<Item <Item
name="email" name="email"
rules={[{ required: true, type: 'email' }]} rules={emailRules}
hasFeedback hasFeedback
> >
<Input <Input
prefix={<MailOutlined />} prefix={<MailOutlined />}
placeholder="Email" placeholder="Email"
autoComplete="email"
/> />
</Item> </Item>
<Item> <Item>

View File

@ -0,0 +1,99 @@
import {
Button,
Divider,
Form,
Input,
} from 'antd';
import { LockOutlined } from '@ant-design/icons';
import {
useEffect,
useState,
} from 'react';
import {
Link,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import {
passwordUpdateFailed,
passwordUpdateSuccess,
} from './notifications';
import { passwordRules } from '../../utils/form';
import validateMessages from './validateMessages';
const { Item } = Form;
const ResetPasswordConfirmation = () => {
const { confirmResetPassword } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const changePassword = async ({ password }: { password: string }) => {
setIsLoading(true);
try {
await confirmResetPassword(userId!, secret!, password);
passwordUpdateSuccess();
navigate(Routes.LOGIN);
} catch (error) {
console.warn(error);
passwordUpdateFailed(error as Error);
form.resetFields();
setIsLoading(false);
}
};
useEffect(() => {
if (!userId || !secret) {
passwordUpdateFailed(new Error('Invalid URL'));
navigate(Routes.HUB);
}
}, [navigate, secret, userId]);
return (
<div className="auth-container">
<Divider>Change password</Divider>
<Form
initialValues={{ remember: true }}
onFinish={changePassword}
validateMessages={validateMessages}
autoComplete="off"
form={form}
>
<Item
name="password"
rules={passwordRules}
hasFeedback
>
<Input.Password
placeholder="New password"
autoComplete="new-password"
prefix={<LockOutlined />}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
icon={<LockOutlined />}
loading={isLoading}
>
Change
</Button>
</Item>
<Link to={Routes.SIGN_UP}>Sign Up</Link>
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
Forgot password?
</Link>
</Form>
</div>
);
};
export default ResetPasswordConfirmation;

View File

@ -1,13 +1,23 @@
import { useState } from 'react'; import {
useCallback,
useEffect,
useState,
} from 'react';
import { import {
Form, Form,
Input, Input,
Button, Button,
Divider, Divider,
Space,
} from 'antd'; } from 'antd';
import { import {
MailOutlined, MailOutlined,
LockOutlined, LockOutlined,
UserOutlined,
GoogleOutlined,
GithubOutlined,
FacebookOutlined,
UserAddOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
Link, Link,
@ -19,85 +29,142 @@ import validateMessages from './validateMessages';
import { import {
emailNotVerified, emailNotVerified,
signUpFailed, signUpFailed,
magicLinkSent,
signUpSuccessful, signUpSuccessful,
} from './notifications'; } from './notifications';
import {
emailRules,
passwordRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form; const { Item } = Form;
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
const SignUp = () => { const SignUp = () => {
const [form] = Form.useForm(); const [formMagicLink] = Form.useForm();
const [isLoading, setIsLoading] = useState(false); const [formEmail] = Form.useForm();
const { signUp } = useAuth(); const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
const {
currentUser,
signUp,
sendEmailVerification,
googleAuth,
githubAuth,
facebookAuth,
sendMagicLink,
} = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const onFinish = async ({ email, password }: { email: string, password: string }) => { const googleLogin = useCallback(async () => {
setIsLoading(true); setIsGoogleLoading(true);
try { try {
await signUp(email, password); await googleAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [googleAuth]);
const githubLogin = useCallback(async () => {
setIsGithubLoading(true);
try {
await githubAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [githubAuth]);
const facebookLogin = useCallback(async () => {
setIsFacebookLoading(true);
try {
await facebookAuth();
} catch (error) {
signUpFailed(error as Error);
}
}, [facebookAuth]);
const emailSignUp = async ({ email, password, username }: { email: string, password: string, username: string }) => {
setIsEmailLoading(true);
try {
const user = await signUp(email, password, username);
await sendEmailVerification();
signUpSuccessful(); signUpSuccessful();
emailNotVerified(); if (!user.emailVerification) {
emailNotVerified();
}
navigate(Routes.HUB); navigate(Routes.HUB);
} catch (error) { } catch (error) {
form.resetFields();
console.warn(error); console.warn(error);
signUpFailed(error as Error); signUpFailed(error as Error);
setIsLoading(false); formMagicLink.resetFields();
formEmail.resetFields();
setIsEmailLoading(false);
} }
}; };
const magicLinkLogin = async ({ email }: { email: string }) => {
setIsMagicLinkLoading(true);
try {
await sendMagicLink(email);
magicLinkSent();
} catch (error) {
signUpFailed(error as Error);
} finally {
setIsMagicLinkLoading(false);
formMagicLink.resetFields();
formEmail.resetFields();
}
};
useEffect(() => {
if (currentUser) {
navigate(Routes.HUB);
}
}, [currentUser, navigate]);
return ( return (
<div className="small-container"> <div className="auth-container">
<Divider>Sign Up</Divider> <Divider>Sign Up</Divider>
<Form <Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
onFinish={onFinish} <Button
validateMessages={validateMessages} loading={isGoogleLoading}
autoComplete="off" onClick={googleLogin}
form={form} disabled={isAnythingLoading}
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
> >
<GoogleOutlined />Google
</Button>
<Button
loading={isGithubLoading}
onClick={githubLogin}
disabled={isAnythingLoading}
>
<GithubOutlined />GitHub
</Button>
<Button
loading={isFacebookLoading}
onClick={facebookLogin}
disabled={isAnythingLoading}
>
<FacebookOutlined />Facebook
</Button>
</Space>
<Divider />
<Form
onFinish={magicLinkLogin}
validateMessages={validateMessages}
form={formMagicLink}
>
<Item name="email" rules={emailRules} hasFeedback>
<Input <Input
prefix={<MailOutlined />} prefix={<MailOutlined />}
placeholder="Email" placeholder="Email"
/> id="email-magic-link"
</Item> autoComplete="email"
<Item disabled={isAnythingLoading}
name="password"
rules={[
{ required: true },
{ pattern: passwordPattern, message: 'Password is too weak!' },
]}
hasFeedback
>
<Input.Password
placeholder="Password"
prefix={<LockOutlined />}
/>
</Item>
<Item
name="passwordConfirmation"
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords don\'t match!'));
},
}),
]}
hasFeedback
>
<Input.Password
placeholder="Password confirmation"
prefix={<LockOutlined />}
/> />
</Item> </Item>
<Item> <Item>
@ -105,15 +172,64 @@ const SignUp = () => {
type="primary" type="primary"
htmlType="submit" htmlType="submit"
style={{ width: '100%' }} style={{ width: '100%' }}
loading={isLoading} loading={isMagicLinkLoading}
disabled={isAnythingLoading}
icon={<MailOutlined />}
> >
Sign Up Send me a Magic Link
</Button> </Button>
</Item> </Item>
</Form> </Form>
<Link to={Routes.LOGIN} style={{ float: 'right' }}> <Form
Log In onFinish={emailSignUp}
</Link> validateMessages={validateMessages}
form={formEmail}
>
<Divider />
<Item name="username" rules={requiredRules} hasFeedback>
<Input
prefix={<UserOutlined />}
placeholder="Username"
autoComplete="name"
disabled={isAnythingLoading}
/>
</Item>
<Item name="email" rules={emailRules} hasFeedback>
<Input
prefix={<MailOutlined />}
placeholder="Email"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item
name="password"
rules={passwordRules}
hasFeedback
>
<Input.Password
placeholder="Password"
autoComplete="new-password"
prefix={<LockOutlined />}
disabled={isAnythingLoading}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UserAddOutlined />}
>
Sign Up using password
</Button>
</Item>
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
Log In
</Link>
</Form>
</div> </div>
); );
}; };

View File

@ -13,6 +13,12 @@ const emailNotVerified = () => notification.warning({
...baseOptions, ...baseOptions,
}); });
const magicLinkSent = () => notification.success({
message: 'Check your email',
description: 'Magic link sent!',
...baseOptions,
});
const signUpSuccessful = () => notification.success({ const signUpSuccessful = () => notification.success({
message: 'Sign Up successful', message: 'Sign Up successful',
description: 'Welcome on board!', description: 'Welcome on board!',
@ -22,6 +28,7 @@ const signUpSuccessful = () => notification.success({
const signUpFailed = (err: Error) => notification.error({ const signUpFailed = (err: Error) => notification.error({
message: 'Failed to create an account', message: 'Failed to create an account',
description: err.message, description: err.message,
...baseOptions,
}); });
const logInSuccessful = () => notification.success({ const logInSuccessful = () => notification.success({
@ -33,6 +40,7 @@ const logInSuccessful = () => notification.success({
const logInFailed = (err: Error) => notification.error({ const logInFailed = (err: Error) => notification.error({
message: 'Failed to log in', message: 'Failed to log in',
description: err.message, description: err.message,
...baseOptions,
}); });
const restrictedPage = () => notification.error({ const restrictedPage = () => notification.error({
@ -50,10 +58,11 @@ const logOutSuccessful = () => notification.success({
const logOutFailed = (err: Error) => notification.error({ const logOutFailed = (err: Error) => notification.error({
message: 'Log out failed', message: 'Log out failed',
description: err.message, description: err.message,
...baseOptions,
}); });
const resetSuccessful = () => notification.success({ const resetSuccessful = () => notification.success({
message: 'Password reset successful', message: 'Password reset initiated',
description: 'Check your email!', description: 'Check your email!',
...baseOptions, ...baseOptions,
}); });
@ -61,10 +70,72 @@ const resetSuccessful = () => notification.success({
const resetFailed = (err: Error) => notification.error({ const resetFailed = (err: Error) => notification.error({
message: 'Password reset failed', message: 'Password reset failed',
description: err.message, description: err.message,
...baseOptions,
});
const magicLinkConfirmationFailed = (err: Error) => notification.error({
message: 'Magic Link is invalid',
description: err.message,
...baseOptions,
});
const sendingEmailVerificationFailed = (err: Error) => notification.success({
message: 'Sending verification email failed',
description: err.message,
...baseOptions,
});
const emailVerificationSent = () => notification.success({
message: 'Check your email',
description: 'Email verification sent!',
...baseOptions,
});
const emailVerificationFailed = (err: Error) => notification.error({
message: 'Email verification failed',
description: err.message,
...baseOptions,
});
const emailVerificationSuccess = () => notification.success({
message: 'Email verified',
description: 'Your email has been verified!',
...baseOptions,
});
const profileUpdateSuccess = () => notification.success({
message: 'Profile updated',
description: 'Your profile has been updated!',
...baseOptions,
});
const profileUpdateFailed = (err: Error) => notification.error({
message: 'Unable to update your profile',
description: err.message,
...baseOptions,
});
const passwordUpdateSuccess = () => notification.success({
message: 'Password changed',
description: 'Your password has been changed!',
...baseOptions,
});
const passwordUpdateFailed = (err: Error) => notification.error({
message: 'Unable to change your password',
description: err.message,
...baseOptions,
});
const databaseGenericError = (err: Error) => notification.error({
message: 'Database Error',
description: err.message,
...baseOptions,
}); });
export { export {
emailNotVerified, emailNotVerified,
magicLinkSent,
signUpSuccessful, signUpSuccessful,
signUpFailed, signUpFailed,
logInSuccessful, logInSuccessful,
@ -74,4 +145,14 @@ export {
logOutFailed, logOutFailed,
resetSuccessful, resetSuccessful,
resetFailed, resetFailed,
magicLinkConfirmationFailed,
sendingEmailVerificationFailed,
emailVerificationSent,
emailVerificationFailed,
emailVerificationSuccess,
profileUpdateSuccess,
profileUpdateFailed,
passwordUpdateSuccess,
passwordUpdateFailed,
databaseGenericError,
}; };

View File

@ -10,11 +10,20 @@ export enum Routes {
TUNE_LOGS = '/t/:tuneId/logs', TUNE_LOGS = '/t/:tuneId/logs',
TUNE_DIAGNOSE = '/t/:tuneId/diagnose', TUNE_DIAGNOSE = '/t/:tuneId/diagnose',
UPLOAD = '/upload',
UPLOAD_WITH_TUNE_ID = '/upload/:tuneId',
LOGIN = '/auth/login', LOGIN = '/auth/login',
LOGOUT = '/auth/logout', LOGOUT = '/auth/logout',
PROFILE = '/auth/profile', PROFILE = '/auth/profile',
SIGN_UP = '/auth/sign-up', SIGN_UP = '/auth/sign-up',
FORGOT_PASSWORD = '/auth/forgot-password', FORGOT_PASSWORD = '/auth/forgot-password',
RESET_PASSWORD = '/auth/reset-password', RESET_PASSWORD = '/auth/reset-password',
UPLOAD = '/upload', MAGIC_LINK_CONFIRMATION = '/auth/magic-link-confirmation',
EMAIL_VERIFICATION = '/auth/email-verification',
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation',
REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION = 'magic-link-confirmation',
REDIRECT_PAGE_EMAIL_VERIFICATION = 'email-verification',
REDIRECT_PAGE_RESET_PASSWORD = 'reset-password',
} }

View File

@ -38,7 +38,7 @@ const initialState: AppState = {
constants: {}, constants: {},
details: {} as any, details: {} as any,
}, },
tuneData: {}, tuneData: {} as any,
logs: [], logs: [],
config: {} as any, config: {} as any,
ui: { ui: {

View File

@ -1,4 +1,8 @@
import { Timestamp } from 'firebase/firestore/lite'; import { Models } from 'appwrite';
type Partial<T> = {
[A in keyof T]?: T[A];
};
export interface TuneDataDetails { export interface TuneDataDetails {
readme?: string | null; readme?: string | null;
@ -17,15 +21,37 @@ export interface TuneDataDetails {
} }
export interface TuneDbData { export interface TuneDbData {
id?: string, userId: string;
userUid?: string; tuneId: string;
createdAt?: Date | Timestamp | string; signature: string;
updatedAt?: Date | Timestamp | string; tuneFileId?: string | null;
isPublished?: boolean; logFileIds?: string[];
isListed?: boolean; toothLogFileIds?: string[];
tuneFile?: string | null; customIniFileId?: string | null;
logFiles?: string[]; vehicleName: string | null;
toothLogFiles?: string[]; engineMake: string | null;
customIniFile?: string | null; engineCode: string | null;
details?: TuneDataDetails; displacement: number | null;
cylindersCount: number | null;
aspiration: 'na' | 'turbocharged' | 'supercharged';
compression?: number | null;
fuel?: string | null;
ignition?: string | null;
injectorsSize?: number | null;
year?: number | null;
hp?: number | null;
stockHp?: number | null;
readme: string | null;
textSearch?: string | null;
}
export interface TuneDbDocument extends TuneDbData, Models.Document {}
export type TuneDbDataPartial = Partial<TuneDbData>;
export interface UsersBucket {
userId: string;
bucketId: string;
visibility: 'pubic' | 'private';
createdAt: number;
} }

View File

@ -3,13 +3,13 @@ import {
Logs, Logs,
TuneWithDetails, TuneWithDetails,
} from '@hyper-tuner/types'; } from '@hyper-tuner/types';
import { TuneDbData } from './dbData'; import { TuneDbDocument } from './dbData';
export interface ConfigState extends Config {} export interface ConfigState extends Config {}
export interface TuneState extends TuneWithDetails {} export interface TuneState extends TuneWithDetails {}
export interface TuneDataState extends TuneDbData {} export interface TuneDataState extends TuneDbDocument {}
export interface LogsState extends Logs {} export interface LogsState extends Logs {}

View File

@ -9,17 +9,26 @@ import {
onProgress as onProgressType, onProgress as onProgressType,
} from './http'; } from './http';
import TuneParser from './tune/TuneParser'; import TuneParser from './tune/TuneParser';
import { TuneDbData } from '../types/dbData'; import { TuneDbDocument } from '../types/dbData';
import useServerStorage, { CDN_URL } from '../hooks/useServerStorage'; import useServerStorage, { CDN_URL } from '../hooks/useServerStorage';
export const loadTune = async (tuneData: TuneDbData) => { // TODO: refactor this!!
export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string) => {
if (tuneData === null) {
store.dispatch({ type: 'config/load', payload: null });
store.dispatch({ type: 'tune/load', payload: null });
return;
}
const pako = await import('pako'); const pako = await import('pako');
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { getFile, getINIFile } = useServerStorage(); const { getFileForDownload, getINIFile } = useServerStorage();
const started = new Date(); const started = new Date();
const tuneRaw = getFile(tuneData.tuneFile!); const tuneRaw = await getFileForDownload(tuneData.tuneFileId!, bucketId);
const tuneParser = new TuneParser() const tuneParser = new TuneParser()
.parse(pako.inflate(new Uint8Array(await tuneRaw))); .parse(pako.inflate(new Uint8Array(tuneRaw)));
if (!tuneParser.isValid()) { if (!tuneParser.isValid()) {
console.error('Invalid tune'); console.error('Invalid tune');
@ -29,7 +38,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
} }
const tune = tuneParser.getTune(); const tune = tuneParser.getTune();
const iniRaw = tuneData.customIniFile ? getFile(tuneData.customIniFile) : getINIFile(tune.details.signature); const iniRaw = tuneData.customIniFileId ? getFileForDownload(tuneData.customIniFileId, bucketId) : getINIFile(tuneData.signature);
const buff = pako.inflate(new Uint8Array(await iniRaw)); const buff = pako.inflate(new Uint8Array(await iniRaw));
const config = new INI(buff).parse().getResults(); const config = new INI(buff).parse().getResults();
@ -45,7 +54,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
config.constants.pages[0].data.divider = divider; config.constants.pages[0].data.divider = divider;
const loadingTimeInfo = `Tune loaded in ${(new Date().getTime() - started.getTime())}ms`; const loadingTimeInfo = `Tune loaded in ${(new Date().getTime() - started.getTime())}ms`;
console.log(loadingTimeInfo); console.info(loadingTimeInfo);
store.dispatch({ type: 'config/load', payload: config }); store.dispatch({ type: 'config/load', payload: config });
store.dispatch({ type: 'tune/load', payload: tune }); store.dispatch({ type: 'tune/load', payload: tune });

View File

@ -8,10 +8,10 @@ export enum Colors {
BLUE = '#2fe3ff', BLUE = '#2fe3ff',
GREY = '#334455', GREY = '#334455',
// dark theme // dark theme - keep this in sync with: src/themes/dark.less and common.less
ACCENT = '#1e88ea', ACCENT = '#2F49D1',
TEXT = '#ddd', TEXT = '#CECECE',
MAIN = '#222629', MAIN = '#1E1E1E',
MAIN_DARK = '#191C1E', MAIN_DARK = '#191C1E',
MAIN_LIGHT = '#2E3338', MAIN_LIGHT = '#252525',
} }

View File

@ -3,3 +3,12 @@ export const environment = import.meta.env.VITE_ENVIRONMENT || 'development';
export const isProduction = environment === 'production'; export const isProduction = environment === 'production';
export const sentryDsn = import.meta.env.VITE_SENTRY_DSN; export const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
export const platform = `${window.navigator.platform}`; export const platform = `${window.navigator.platform}`;
export const fetchEnv = (envName: string): string => {
const envValue = import.meta.env[envName];
if (envValue === '' || envValue === null || envValue === undefined) {
throw new Error(`Missing ENV: ${envName}`);
}
return envValue;
};

28
src/utils/form.ts Normal file
View File

@ -0,0 +1,28 @@
import { Rule } from 'antd/lib/form';
const REQUIRED_MESSAGE = 'This field is required';
// eslint-disable-next-line import/prefer-default-export
export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
export const passwordRules: Rule[] = [
{ required: true },
{ pattern: passwordPattern, message: 'Password is too weak!' },
];
export const emailRules: Rule[] = [{
required: true,
type: 'email',
whitespace: true,
}];
export const requiredTextRules: Rule[] = [{
required: true,
message: REQUIRED_MESSAGE,
whitespace: true,
}];
export const requiredRules: Rule[] = [{
required: true,
message: REQUIRED_MESSAGE,
}];

View File

@ -30,9 +30,9 @@ export const msToTime = (input: number) => {
export const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin; export const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
export const colorHsl = (min: number, max: number, value: number): HslType => { export const colorHsl = (min: number, max: number, value: number): HslType => {
const saturation = 60; const saturation = 80;
const lightness = 40; const lightness = 45;
const coldDeg = 220; const coldDeg = 225;
const hotDeg = 0; const hotDeg = 0;
let hue = remap(value, min, max, coldDeg, hotDeg); let hue = remap(value, min, max, coldDeg, hotDeg);

View File

@ -20,8 +20,14 @@ class TuneParser {
const raw = (new TextDecoder()).decode(buffer); const raw = (new TextDecoder()).decode(buffer);
const xml = (new DOMParser()).parseFromString(raw, 'text/xml'); const xml = (new DOMParser()).parseFromString(raw, 'text/xml');
const xmlPages = xml.getElementsByTagName('page'); const xmlPages = xml.getElementsByTagName('page');
const bibliography = xml.getElementsByTagName('bibliography')[0].attributes as any; const bibliography = xml.getElementsByTagName('bibliography')[0]?.attributes as any;
const versionInfo = xml.getElementsByTagName('versionInfo')[0].attributes as any; const versionInfo = xml.getElementsByTagName('versionInfo')[0]?.attributes as any;
if (!xmlPages || !bibliography || !versionInfo) {
this.isTuneValid = false;
return this;
}
this.tune.details = { this.tune.details = {
author: bibliography.author.value, author: bibliography.author.value,
@ -64,7 +70,7 @@ class TuneParser {
this.isTuneValid = true; this.isTuneValid = true;
} }
if (this.tune.details.signature.match(/^speeduino \d+$/) === null) { if (this.isSignatureSupported()) {
this.isTuneValid = false; this.isTuneValid = false;
} }
@ -78,6 +84,10 @@ class TuneParser {
isValid(): boolean { isValid(): boolean {
return this.isTuneValid; return this.isTuneValid;
} }
private isSignatureSupported(): boolean {
return this.tune.details.signature.match(/^speeduino \d+$/) === null;
}
} }
export default TuneParser; export default TuneParser;

View File

@ -0,0 +1,6 @@
// eslint-disable-next-line import/prefer-default-export
export const aspirationMapper: { [key:string]: string } = {
na: 'N/A',
turbocharged: 'Turbocharged',
supercharged: 'Supercharged',
};

View File

@ -1,2 +1,6 @@
// eslint-disable-next-line import/prefer-default-export import { fetchEnv } from './env';
export const generateShareUrl = (tuneId: string) => `${import.meta.env.VITE_WEB_URL}/#/t/${tuneId}`;
export const buildFullUrl = (parts = [] as string[]) => `${fetchEnv('VITE_WEB_URL')}/#${parts.join('/')}`;
export const buildRedirectUrl = (page: string) => `${fetchEnv('VITE_WEB_URL')}?redirectPage=${page}`;

View File

@ -17,7 +17,9 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"incremental": false,
"noUncheckedIndexedAccess": false
}, },
"include": [ "include": [
"src" "src"

View File

@ -9,14 +9,6 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
firebase: [
'firebase/app',
'firebase/performance',
'firebase/auth',
'firebase/analytics',
'firebase/storage',
'firebase/firestore/lite',
],
react: ['react', 'react-dom'], react: ['react', 'react-dom'],
antdResult: ['antd/es/result'], antdResult: ['antd/es/result'],
antdTable: ['antd/es/table'], antdTable: ['antd/es/table'],
@ -29,7 +21,10 @@ export default defineConfig({
}, },
}, },
}, },
server: { open: true }, server: {
open: true,
host: '0.0.0.0',
},
css: { css: {
preprocessorOptions: { preprocessorOptions: {
less: { javascriptEnabled: true }, less: { javascriptEnabled: true },