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_WEB_URL=http://localhost:3000
VITE_SENTRY_DSN=
VITE_FIREBASE_APP_SENTRY_DSN=
VITE_FIREBASE_API_KEY=
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=
# TODO: remove this later
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
registries:
npm-github:
@ -10,8 +5,8 @@ registries:
url: https://npm.pkg.github.com
token: ${{ secrets.NPM_GITHUB_PAT }}
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 20

14
.gitignore vendored
View File

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

1
.nvmrc Normal file
View File

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

View File

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

View File

@ -4,13 +4,7 @@ This guide will help you set up this project.
## Requirements
- [Node](https://nodejs.org/) 16.x.x (Node Version Manager: [nvm](https://github.com/nvm-sh/nvm))
- [Firebase](https://console.firebase.google.com/)
- Authentication
- Storage
- Firestore Database
- [Firebase CLI](https://firebase.google.com/docs/cli)
- [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) (`brew install --cask google-cloud-sdk`)
- Node Version Manager: [nvm](https://github.com/nvm-sh/nvm)
### 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
```
### Setup correct Node.js version
```bash
nvm use
```
### Install dependencies and run in development mode
```bash
@ -43,19 +43,3 @@ npm install
# run development server
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>
<meta charset="utf-8" />
<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="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://apis.google.com" crossorigin>
<meta property="og:title" 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" />
<title>HyperTuner Cloud</title>
</head>
<body style="background-color: #222629;">
<body style="background-color: #191C1E">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- 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",
"serve": "vite preview",
"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": {
"@reduxjs/toolkit": "^1.7.2",
"@sentry/react": "^6.18.0",
"@sentry/tracing": "^6.18.0",
"@hyper-tuner/ini": "^0.3.0",
"@hyper-tuner/types": "^0.3.0",
"antd": "^4.18.8",
"firebase": "^9.6.7",
"kbar": "^0.1.0-beta.34",
"@hyper-tuner/ini": "^0.3.1",
"@hyper-tuner/types": "^0.3.3",
"@reduxjs/toolkit": "^1.8.3",
"@sentry/react": "^7.7.0",
"@sentry/tracing": "^7.7.0",
"antd": "^4.21.6",
"appwrite": "^9.0.1",
"kbar": "^0.1.0-beta.36",
"lodash.debounce": "^4.0.8",
"mlg-converter": "^0.5.1",
"nanoid": "^3.3.1",
"nanoid": "^4.0.0",
"pako": "^2.0.4",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-markdown": "^8.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.3",
"react-perfect-scrollbar": "^1.5.8",
"react-redux": "^8.0.1",
"react-router-dom": "^6.2.1",
"uplot": "^1.6.19",
"react-redux": "^8.0.2",
"react-router-dom": "^6.3.0",
"uplot": "^1.6.22",
"uplot-react": "^1.1.1",
"vite": "^2.8.4"
"vite": "^3.0.0"
},
"devDependencies": {
"@hyper-tuner/eslint-config": "^0.1.5",
"@types/node": "^17.0.19",
"@types/pako": "^1.0.3",
"@types/react": "^18.0.3",
"@types/react-dom": "^18.0.3",
"@types/react-redux": "^7.1.22",
"@hyper-tuner/eslint-config": "^0.1.6",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.0.5",
"@types/pako": "^2.0.0",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.12.1",
"@typescript-eslint/parser": "^5.21.0",
"@vitejs/plugin-react": "^1.2.0",
"eslint": "^8.14.0",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-react": "^2.0.0",
"eslint": "^8.20.0",
"eslint-plugin-flowtype": "^8.0.3",
"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-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"less": "^4.1.2",
"prettier": "^2.5.1",
"rollup-plugin-visualizer": "^5.6.0",
"typescript": "^4.5.5"
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"less": "^4.1.3",
"prettier": "^2.7.1",
"rollup-plugin-visualizer": "^5.7.1",
"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" : [
{
"src": "/img/screen1.png",
"sizes": "1920x1109",
"sizes": "1920x1194",
"type": "image/png",
"platform": "wide",
"label": "VE Table with command palette"
},
{
"src": "/img/screen2.png",
"sizes": "1920x1111",
"sizes": "1920x1194",
"type": "image/png",
"platform": "wide",
"label": "Log viewer"
@ -32,6 +32,6 @@
],
"start_url": ".",
"display": "standalone",
"theme_color": "#222629",
"background_color": "#222629"
"theme_color": "#191C1E",
"background_color": "#191C1E"
}

View File

@ -2,6 +2,7 @@ import {
Routes as ReactRoutes,
Route,
useMatch,
useNavigate,
} from 'react-router-dom';
import {
Layout,
@ -14,6 +15,7 @@ import {
Suspense,
useCallback,
useEffect,
useState,
} from 'react';
import TopBar from './components/TopBar';
import StatusBar from './components/StatusBar';
@ -25,6 +27,7 @@ import Loader from './components/Loader';
import {
AppState,
NavigationState,
TuneDataState,
UIState,
} from './types/state';
import useDb from './hooks/useDb';
@ -32,7 +35,7 @@ import Info from './pages/Info';
import Hub from './pages/Hub';
import 'react-perfect-scrollbar/dist/css/styles.css';
import './App.less';
import './css/App.less';
// TODO: fix this
// lazy loading this component causes a weird Curve canvas scaling
@ -40,11 +43,14 @@ import './App.less';
const Tune = lazy(() => import('./pages/Tune'));
const Diagnose = lazy(() => import('./pages/Diagnose'));
const Upload = lazy(() => import('./pages/Upload'));
const Login = lazy(() => import('./pages/auth/Login'));
const Profile = lazy(() => import('./pages/auth/Profile'));
const SignUp = lazy(() => import('./pages/auth/SignUp'));
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;
@ -52,11 +58,32 @@ const mapStateToProps = (state: AppState) => ({
ui: state.ui,
status: state.status,
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 { 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 = storageGetSync('lastDialog');
@ -66,9 +93,25 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
useEffect(() => {
if (tuneId) {
getTune(tuneId).then(async (tuneData) => {
loadTune(tuneData);
store.dispatch({ type: 'tuneData/load', payload: tuneData });
// clear out last state
if (tuneData && tuneId !== tuneData.tuneId) {
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 });
@ -92,15 +135,17 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
<Layout style={{ marginLeft }}>
<Layout className="app-content">
<Content>
<Suspense fallback={<Loader />}>
{element}
</Suspense>
<Suspense fallback={<Loader />}>{element}</Suspense>
</Content>
</Layout>
</Layout>
);
}, []);
if (isLoading) {
return <Loader />;
}
return (
<>
<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_LOGS} element={<ContentFor marginLeft={margin} element={<Logs />} />} />
<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.PROFILE} element={<ContentFor element={<Profile />} />} />
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
<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>
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
</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 {
config: ConfigType;
tune: TuneType;
config: ConfigType | null;
tune: TuneType | null;
navigation: NavigationState;
// eslint-disable-next-line react/no-unused-prop-types
children?: ReactNode;
@ -289,14 +289,14 @@ const ActionsProvider = (props: CommandPaletteProps) => {
}, [navigate, navigation.tuneId]);
const getActions = () => {
if (Object.keys(tune.constants).length) {
return generateActions(config.menus);
if (tune?.constants && Object.keys(tune.constants).length) {
return generateActions(config!.menus);
}
return [];
};
useRegisterActions(getActions(), [tune.constants]);
useRegisterActions(getActions(), [tune?.constants]);
return null;
};

View File

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

View File

@ -166,6 +166,28 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
return list.length ? list : null;
}, [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 (
<Header className="app-top-bar">
<Row>
@ -225,30 +247,8 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
</Button>
</Dropdown>
<Dropdown
overlay={
<Menu>
{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"
overlay={<Menu items={userMenuItems} />}
placement="bottomRight"
trigger={['click']}
>
<Button icon={<UserOutlined />}>

View File

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

View File

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

View File

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

View File

@ -1,15 +1,3 @@
import {
User,
UserCredential,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
sendEmailVerification,
signOut,
sendPasswordResetEmail,
GoogleAuthProvider,
GithubAuthProvider,
signInWithPopup,
} from 'firebase/auth';
import {
createContext,
ReactNode,
@ -18,19 +6,118 @@ import {
useMemo,
useState,
} 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 {
currentUser: User | null,
signUp: (email: string, password: string) => Promise<void>,
login: (email: string, password: string) => Promise<UserCredential>,
signUp: (email: string, password: string, username: string) => Promise<User>,
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>,
resetPassword: (email: string) => Promise<void>,
initResetPassword: (email: string) => Promise<void>,
googleAuth: () => 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 useAuth = () => useContext<AuthValue>(AuthContext as any);
@ -42,28 +129,145 @@ const AuthProvider = (props: { children: ReactNode }) => {
const value = useMemo(() => ({
currentUser,
signUp: (email: string, password: string) => createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => sendEmailVerification(userCredential.user)),
login: (email: string, password: string) => signInWithEmailAndPassword(auth, email, password),
logout: () => signOut(auth),
resetPassword: (email: string) => sendPasswordResetEmail(auth, email),
signUp: async (email: string, password: string, username: string) => {
try {
await account.create('unique()', email, password, username);
await account.createEmailSession(email, password);
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 () => {
const provider = new GoogleAuthProvider().addScope('https://www.googleapis.com/auth/userinfo.email');
const credentials = await signInWithPopup(auth, provider);
setCurrentUser(credentials.user);
account.createOAuth2Session(
'google',
OAUTH_REDIRECT_URL,
OAUTH_REDIRECT_URL,
GOOGLE_SCOPES,
);
},
githubAuth: async () => {
const provider = new GithubAuthProvider().addScope('user:email');
const credentials = await signInWithPopup(auth, provider);
setCurrentUser(credentials.user);
account.createOAuth2Session(
'github',
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]);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
account.get().then((user) => {
console.info('Logged as:', user.name || 'Unknown');
setCurrentUser(user);
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;
@ -71,7 +275,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
return (
<AuthContext.Provider value={value}>
{!isLoading && children}
{isLoading ? <Loader /> : children}
</AuthContext.Provider>
);
};

View File

@ -4,14 +4,7 @@
@import './themes/dark.less';
@import './themes/common.less';
@import './themes/ant.less';
:root {
--background: @component-background;
--foreground: @text;
--a1: @main;
--border: @border-color-split;
--shadow: @shadow-2;
}
@import './overrides.less';
body {
overflow: hidden;
@ -36,13 +29,6 @@ html, body {
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 {
position: fixed;
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 {
height: calc(100vh - @layout-header-height - @layout-footer-height);
overflow-y: auto;
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,
.large-container,
.auth-container {
@ -83,25 +82,10 @@ html, body {
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 {
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 {
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;
@border-radius-base: 6px;

View File

@ -1,9 +1,9 @@
// darker
@main: #222629;
@main-dark: #191C1E;
@main-light: #2E3338;
@text: #ddd;
@text: #CECECE;
@main: #191C1E;
@main-dark: #1E1E1E;
@main-light: #252525;
@main-darkest: #171717;
// lighter
// @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;
};
const useConfig = (config: ConfigType) => useMemo(() => ({
isConfigReady: !!config.constants,
findOutputChannel: (name: string) => findOutputChannel(config, name),
findConstantOnPage: (name: string) => findConstantOnPage(config, name),
findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config, label),
findDatalog: (name: string) => findDatalog(config, name),
const useConfig = (config: ConfigType | null) => useMemo(() => ({
isConfigReady: !!config?.constants,
findOutputChannel: (name: string) => findOutputChannel(config!, name),
findConstantOnPage: (name: string) => findConstantOnPage(config!, name),
findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config!, label),
findDatalog: (name: string) => findDatalog(config!, name),
}), [config]);
export default useConfig;

View File

@ -1,84 +1,126 @@
import { notification } from 'antd';
import * as Sentry from '@sentry/browser';
import {
Timestamp,
doc,
getDoc,
setDoc,
collection,
where,
query,
getDocs,
QuerySnapshot,
orderBy,
getFirestore,
} from 'firebase/firestore/lite';
import { TuneDbData } from '../types/dbData';
Models,
Query,
} from 'appwrite';
import { database } from '../appwrite';
import {
TuneDbData,
UsersBucket,
TuneDbDataPartial,
TuneDbDocument,
} from '../types/dbData';
import { databaseGenericError } from '../pages/auth/notifications';
import { fetchEnv } from '../utils/env';
const TUNES_PATH = 'publicTunes';
const db = getFirestore();
const genericError = (error: Error) => notification.error({ message: 'Database Error', description: error.message });
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 useDb = () => {
const getTuneData = async (tuneId: string) => {
const updateTune = async (documentId: string, data: TuneDbDataPartial) => {
try {
const tune = (await getDoc(doc(db, TUNES_PATH, tuneId))).data() as TuneDbData;
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 });
await database.updateDocument(COLLECTION_ID_PUBLIC_TUNES, documentId, data);
return Promise.resolve();
} catch (error) {
Sentry.captureException(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 {
updateData: (tuneId: string, data: TuneDbData): Promise<void> => updateData(tuneId, data),
getTune: (tuneId: string): Promise<TuneDbData> => getTuneData(tuneId),
listTunes: (): Promise<QuerySnapshot<TuneDbData>> => listTunesData(),
updateTune: (tuneId: string, data: TuneDbDataPartial): Promise<void> => updateTune(tuneId, data),
createTune: (data: TuneDbData): Promise<Models.Document> => createTune(data),
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 * as Sentry from '@sentry/browser';
import {
UploadTask,
ref,
getBytes,
deleteObject,
uploadBytesResumable,
getStorage,
} from 'firebase/storage';
import { Models } from 'appwrite';
import { storage } from '../appwrite';
import { fetchEnv } from '../utils/env';
const PUBLIC_PATH = 'public';
const USERS_PATH = `${PUBLIC_PATH}/users`;
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 fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
if (CDN_URL) {
const response = await fetch(`${CDN_URL}/${path}`);
return Promise.resolve(response.arrayBuffer());
}
return Promise.resolve(await getBytes(ref(storage, path)));
const response = await fetch(`${CDN_URL}/${path}`);
return Promise.resolve(response.arrayBuffer());
};
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 { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
@ -52,7 +29,7 @@ const useServerStorage = () => {
notification.warning({
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 {
@ -63,7 +40,7 @@ const useServerStorage = () => {
notification.error({
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 {
await deleteObject(ref(storage, path));
await storage.deleteFile(bucketId, fileId);
return Promise.resolve();
} catch (error) {
Sentry.captureException(error);
@ -85,20 +61,61 @@ const useServerStorage = () => {
}
};
const uploadFile = (path: string, file: File, data: Uint8Array) =>
uploadBytesResumable(ref(storage, path), data, {
customMetadata: {
name: file.name,
size: `${file.size}`,
},
});
const uploadFile = async (userId: string, bucketId: string, file: File) => {
try {
const createdFile = await storage.createFile(
bucketId,
'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 {
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),
removeFile: (path: string): Promise<void> => removeFile(path),
uploadFile: (path: string, file: File, data: Uint8Array): UploadTask => uploadFile(path, file, data),
basePathForFile: (userUuid: string, tuneId: string, fileName: string): string => `${USERS_PATH}/${userUuid}/tunes/${tuneId}/${fileName}`,
removeFile: (bucketId: string, fileId: string): Promise<void> => removeFile(bucketId, fileId),
getFileForDownload: (bucketId: string, fileId: string): Promise<ArrayBuffer> => getFileForDownload(bucketId, fileId),
uploadFile: (userId: string, bucketId: string, file: File): Promise<ServerFile> => uploadFile(userId, bucketId, file),
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,6 @@ import {
Row,
Select,
Space,
Switch,
Tabs,
Tooltip,
Typography,
@ -35,13 +34,11 @@ import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface';
import {
generatePath,
useMatch,
useNavigate,
} from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import {
customAlphabet,
nanoid,
} from 'nanoid';
import { nanoid } from 'nanoid';
import {
emailNotVerified,
restrictedPage,
@ -52,9 +49,15 @@ import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogParser from '../utils/logs/LogParser';
import useDb from '../hooks/useDb';
import useServerStorage from '../hooks/useServerStorage';
import { generateShareUrl } from '../utils/url';
import useServerStorage, { ServerFile } from '../hooks/useServerStorage';
import { buildFullUrl } from '../utils/url';
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;
@ -65,22 +68,13 @@ enum MaxFiles {
CUSTOM_INI_FILES = 1,
}
type Path = string;
interface UploadedFile {
[autoUid: string]: Path;
}
interface UploadFileData {
path: string;
}
interface ValidationResult {
result: boolean;
message: string;
}
type ValidateFile = (file: File) => Promise<ValidationResult>;
type UploadDone = (fileCreated: ServerFile, file: File) => void;
const rowProps = { gutter: 10 };
const colProps = { span: 24, sm: 12 };
@ -88,31 +82,48 @@ const colProps = { span: 24, sm: 12 };
const maxFileSizeMB = 50;
const descriptionEditorHeight = 260;
const thisYear = (new Date()).getFullYear();
const nanoidCustom = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
const generateTuneId = () => nanoid(10);
const tuneIcon = () => <ToolOutlined />;
const logIcon = () => <FundOutlined />;
const toothLogIcon = () => <SettingOutlined />;
const iniIcon = () => <FileTextOutlined />;
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const tuneParser = new TuneParser();
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 [tuneDocumentId, setTuneDocumentId] = useState<string>();
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
const [shareUrl, setShareUrl] = useState<string>();
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isPublished, setIsPublished] = useState(false);
const [tuneFile, setTuneFile] = useState<UploadedFile | null | false>(null);
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 [isEditMode, setIsEditMode] = useState(false);
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 = () => { };
@ -130,29 +141,51 @@ const UploadPage = () => {
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);
await updateData(newTuneId!, {
id: newTuneId!,
userUid: currentUser!.uid,
updatedAt: new Date(),
isPublished: true,
isListed: values.isListed,
details: {
readme: readme || null,
make: values.make || null,
model: values.model || null,
displacement: values.displacement || null,
year: values.year || null,
hp: values.hp || null,
stockHp: values.stockHp || null,
engineCode: values.engineCode || null,
cylindersCount: values.cylindersCount || null,
aspiration: values.aspiration || null,
fuel: values.fuel || null,
injectorsSize: values.injectorsSize || null,
coils: values.coils || null,
},
await updateTune(tuneDocumentId!, {
vehicleName,
engineMake,
engineCode,
displacement,
cylindersCount,
aspiration,
compression,
fuel,
ignition,
injectorsSize,
year,
hp,
stockHp,
readme: readme?.trim(),
textSearch: [
vehicleName,
engineMake,
engineCode,
`${displacement}l`,
aspirationMapper[aspiration] || null,
fuel,
ignition,
year,
].filter((field) => field !== null && `${field}`.length > 1)
.join(' ')
.replace(/[^A-z\d ]/g, ''),
});
setIsLoading(false);
setIsPublished(true);
@ -163,8 +196,14 @@ const UploadPage = () => {
message: `File should not be larger than ${maxFileSizeMB}MB!`,
});
const upload = async (path: string, options: UploadRequestOption, done: Function, validate: ValidateFile) => {
const { onError, onSuccess, onProgress, file } = options;
const navigateToNewTuneId = useCallback(() => {
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);
if (!validation.result) {
@ -172,6 +211,7 @@ const UploadPage = () => {
const errorMessage = validation.message;
notification.error({ message: errorName, description: errorMessage });
onError!({ name: errorName, message: errorMessage });
return false;
}
@ -179,96 +219,68 @@ const UploadPage = () => {
const pako = await import('pako');
const buffer = await (file as File).arrayBuffer();
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(
'state_changed',
(snap) => onProgress!({ percent: (snap.bytesTransferred / snap.totalBytes) * 100 }),
(err) => onError!(err),
() => {
onSuccess!(file);
if (done) done();
},
);
done(fileCreated, file as File);
onSuccess!(null);
} catch (error) {
Sentry.captureException(error);
console.error('Upload error:', error);
notification.error({ message: 'Upload error', description: (error as Error).message });
onError!(error as Error);
return false;
}
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) => {
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);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
if (tuneDocumentId) {
await updateTune(tuneDocumentId, {
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, () => {
// 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,
});
setTuneFileId(fileCreated.$id);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
setTuneFile(false);
return { result, message };
}
const valid = (new TuneParser()).parse(await file.arrayBuffer()).isValid();
if (!valid) {
setTuneFile(false);
} else {
setTuneFile(tune);
}
return {
result: valid,
result: tuneParser.parse(await file.arrayBuffer()).isValid(),
message: 'Tune file is not valid!',
};
});
};
const uploadLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
const uuid = (options.file as UploadFile).uid;
tune[uuid] = path;
const newValues = { ...logFiles, ...tune };
upload(path, options, () => {
updateData(newTuneId!, { logFiles: Object.values(newValues) });
upload(options, async (fileCreated) => {
const newValues = new Map(logFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
await updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
setLogFileIds(newValues);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -292,10 +304,6 @@ const UploadPage = () => {
break;
}
if (valid) {
setLogFiles(newValues);
}
return {
result: valid,
message: 'Log file is empty or not valid!',
@ -304,12 +312,10 @@ const UploadPage = () => {
};
const uploadToothLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
const newValues = { ...toothLogFiles, ...tune };
upload(path, options, () => {
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
upload(options, async (fileCreated) => {
const newValues = new Map(toothLogFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
await updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
setToothLogFileIds(newValues);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -317,25 +323,18 @@ const UploadPage = () => {
}
const parser = new TriggerLogsParser(await file.arrayBuffer());
const valid = parser.isComposite() || parser.isTooth();
if (valid) {
setToothLogFiles(newValues);
}
return {
result: valid,
result: parser.isComposite() || parser.isTooth(),
message: 'Tooth logs file is empty or not valid!',
};
});
};
const uploadCustomIni = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
upload(path, options, () => {
updateData(newTuneId!, { customIniFile: path });
upload(options, async (fileCreated) => {
await updateTune(tuneDocumentId!, { customIniFileId: fileCreated.$id });
setCustomIniFileId(fileCreated.$id);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
@ -352,10 +351,6 @@ const UploadPage = () => {
validationMessage = (error as Error).message;
}
if (valid) {
setCustomIniFile(tune);
}
return {
result: valid,
message: validationMessage,
@ -363,44 +358,99 @@ const UploadPage = () => {
});
};
const removeTuneFile = async (file: UploadFile) => {
if (tuneFile) {
removeFile(tuneFile[file.uid]);
}
setTuneFile(null);
updateData(newTuneId!, { tuneFile: null });
const removeFileFromStorage = async (fileId: string) => {
await removeFile(await getBucketId(currentUser!.$id), fileId);
};
const removeTuneFile = async () => {
await removeFileFromStorage(tuneFileId!);
await updateTune(tuneDocumentId!, { tuneFileId: null });
setTuneFileId(null);
};
const removeLogFile = async (file: UploadFile) => {
const { uid } = file;
if (logFiles[file.uid]) {
removeFile(logFiles[file.uid]);
}
const newValues = { ...logFiles };
delete newValues[uid];
setLogFiles(newValues);
updateData(newTuneId!, { logFiles: Object.values(newValues) });
await removeFileFromStorage(logFileIds.get(file.uid)!);
logFileIds.delete(file.uid);
const newValues = new Map(logFileIds);
setLogFileIds(newValues);
updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
};
const removeToothLogFile = async (file: UploadFile) => {
const { uid } = file;
if (toothLogFiles[file.uid]) {
removeFile(toothLogFiles[file.uid]);
}
const newValues = { ...toothLogFiles };
delete newValues[uid];
setToothLogFiles(newValues);
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
await removeFileFromStorage(toothLogFileIds.get(file.uid)!);
toothLogFileIds.delete(file.uid);
const newValues = new Map(toothLogFileIds);
setToothLogFileIds(newValues);
updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
};
const removeCustomIniFile = async (file: UploadFile) => {
if (customIniFile) {
removeFile(customIniFile![file.uid]);
}
setCustomIniFile(null);
updateData(newTuneId!, { customIniFile: null });
await removeFileFromStorage(customIniFileId!);
await updateTune(tuneDocumentId!, { customIniFileId: null });
setCustomIniFileId(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 () => {
if (!currentUser) {
restrictedPage();
@ -410,8 +460,7 @@ const UploadPage = () => {
}
try {
await refreshToken();
if (!currentUser.emailVerified) {
if (!currentUser.emailVerification) {
emailNotVerified();
navigate(Routes.LOGIN);
@ -424,14 +473,18 @@ const UploadPage = () => {
genericError(error as Error);
}
const tuneId = nanoidCustom();
setNewTuneId(tuneId);
console.log('New tuneId:', tuneId);
}, [currentUser, navigate, refreshToken]);
const currentTuneId = routeMatch?.params.tuneId;
if (currentTuneId) {
loadExistingTune(currentTuneId);
setShareUrl(buildFullUrl([tunePath(currentTuneId)]));
} else {
navigateToNewTuneId();
}
}, [currentUser, loadExistingTune, navigate, navigateToNewTuneId, routeMatch?.params.tuneId]);
useEffect(() => {
prepareData();
}, [currentUser, prepareData, refreshToken]);
}, [currentUser, prepareData]);
const uploadButton = (
<Space direction="vertical">
@ -467,7 +520,7 @@ const UploadPage = () => {
loading={isLoading}
htmlType="submit"
>
Publish
{isEditMode ? 'Update' : 'Publish'}
</Button> : <Button
type="primary"
block
@ -486,78 +539,85 @@ const UploadPage = () => {
<Space>Details</Space>
</Divider>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="make" rules={requiredRules}>
<Input addonBefore="Make"/>
</Item>
</Col>
<Col {...colProps}>
<Item name="model" rules={requiredRules}>
<Input addonBefore="Model"/>
<Col span={24} sm={24}>
<Item name="vehicleName" rules={requiredTextRules}>
<Input addonBefore="Vehicle name" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="year" rules={requiredRules}>
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
<Item name="engineMake" rules={requiredTextRules}>
<Input addonBefore="Engine make" />
</Item>
</Col>
<Col {...colProps}>
<Item name="engineCode" rules={requiredTextRules}>
<Input addonBefore="Engine code" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="displacement" rules={requiredRules}>
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="hp">
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} />
</Item>
</Col>
<Col {...colProps}>
<Item name="stockHp">
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} />
<Item name="cylindersCount" rules={requiredRules}>
<InputNumber addonBefore="Cylinders" style={{ width: '100%' }} min={0} max={16} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="engineCode">
<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">
<Item name="aspiration" rules={requiredTextRules}>
<Select placeholder="Aspiration" style={{ width: '100%' }}>
<Select.Option value="na">Naturally aspirated</Select.Option>
<Select.Option value="turbocharger">Turbocharged</Select.Option>
<Select.Option value="supercharger">Supercharged</Select.Option>
<Select.Option value="turbocharged">Turbocharged</Select.Option>
<Select.Option value="supercharged">Supercharged</Select.Option>
</Select>
</Item>
</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}>
<Item name="fuel">
<Input addonBefore="Fuel" />
</Item>
</Col>
<Col {...colProps}>
<Item name="ignition">
<Input addonBefore="Ignition" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="injectorsSize">
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} />
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} max={100_000} />
</Item>
</Col>
<Col {...colProps}>
<Item name="coils">
<Input addonBefore="Coils" />
<Item name="year">
<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>
</Col>
</Row>
@ -587,12 +647,6 @@ const UploadPage = () => {
</div>
</Tabs.TabPane>
</Tabs>
<Divider>
Visibility
</Divider>
<Item name="isListed" label="Listed:" valuePropName="checked">
<Switch />
</Item>
</>
);
@ -605,18 +659,19 @@ const UploadPage = () => {
</Space>
</Divider>
<Upload
key={defaultLogFilesList.map((file) => file.uid).join('-') || 'logs'}
listType="picture-card"
customRequest={uploadLogs}
data={logFileData}
onRemove={removeLogFile}
iconRender={logIcon}
multiple
maxCount={MaxFiles.LOG_FILES}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultLogFilesList}
accept=".mlg,.csv,.msl"
>
{Object.keys(logFiles).length < MaxFiles.LOG_FILES && uploadButton}
{logFileIds.size < MaxFiles.LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
@ -625,17 +680,18 @@ const UploadPage = () => {
</Space>
</Divider>
<Upload
key={defaultToothLogFilesList.map((file) => file.uid).join('-') || 'toothLogs'}
listType="picture-card"
customRequest={uploadToothLogs}
data={toothLogFilesData}
onRemove={removeToothLogFile}
iconRender={toothLogIcon}
multiple
maxCount={MaxFiles.TOOTH_LOG_FILES}
onPreview={noop}
defaultFileList={defaultToothLogFilesList}
accept=".csv"
>
{Object.keys(toothLogFiles).length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
{toothLogFileIds.size < MaxFiles.TOOTH_LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
@ -644,23 +700,24 @@ const UploadPage = () => {
</Space>
</Divider>
<Upload
key={defaultCustomIniFileList[0]?.uid || 'customIni'}
listType="picture-card"
customRequest={uploadCustomIni}
data={customIniFileData}
onRemove={removeCustomIniFile}
iconRender={iniIcon}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultCustomIniFileList}
accept=".ini"
>
{!customIniFile && uploadButton}
{!customIniFileId && uploadButton}
</Upload>
{detailsSection}
{shareUrl && tuneFile && shareSection}
{shareUrl && tuneFileId && shareSection}
</>
);
if (!isUserAuthorized) {
if (!isUserAuthorized || isTuneLoading) {
return <Loader />;
}
@ -674,13 +731,7 @@ const UploadPage = () => {
return (
<div className="small-container">
<Form
onFinish={publish}
initialValues={{
readme: '# My Tune\n\ndescription',
isListed: true,
}}
>
<Form onFinish={publishTune} initialValues={initialValues}>
<Divider>
<Space>
Upload Tune
@ -688,18 +739,19 @@ const UploadPage = () => {
</Space>
</Divider>
<Upload
key={defaultTuneFileList[0]?.uid || 'tuneFile'}
listType="picture-card"
customRequest={uploadTune}
data={tuneFileData}
onRemove={removeTuneFile}
iconRender={tuneIcon}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultTuneFileList}
accept=".msq"
>
{tuneFile === null && uploadButton}
{tuneFileId === null && uploadButton}
</Upload>
{tuneFile && optionalSection}
{(tuneFileId || defaultTuneFileList.length > 0) && optionalSection}
</Form>
</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 {
MailOutlined,
LockOutlined,
UnlockOutlined,
GoogleOutlined,
GithubOutlined,
FacebookOutlined,
} from '@ant-design/icons';
import {
Link,
@ -26,90 +28,166 @@ import {
emailNotVerified,
logInFailed,
logInSuccessful,
magicLinkSent,
} from './notifications';
import {
emailRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form;
const Login = () => {
const [form] = Form.useForm();
const [formMagicLink] = Form.useForm();
const [formEmail] = Form.useForm();
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = 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 isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading;
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
const googleLogin = useCallback(async () => {
setIsGoogleLoading(true);
try {
await googleAuth();
logInSuccessful();
redirectAfterLogin();
} catch (error) {
logInFailed(error as Error);
setIsGoogleLoading(false);
}
}, [googleAuth, redirectAfterLogin]);
}, [googleAuth]);
const githubLogin = useCallback(async () => {
setIsGithubLoading(true);
try {
await githubAuth();
logInSuccessful();
redirectAfterLogin();
} catch (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);
try {
const userCredentials = await login(email, password);
const user = await login(email, password);
logInSuccessful();
if (!userCredentials.user.emailVerified) {
if (!user.emailVerification) {
emailNotVerified();
}
if (!user.name) {
navigate(Routes.PROFILE);
}
redirectAfterLogin();
} catch (error) {
form.resetFields();
console.warn(error);
logInFailed(error as Error);
formMagicLink.resetFields();
formEmail.resetFields();
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 (
<div className="small-container">
<Divider>Log In using email</Divider>
<Form
onFinish={emailLogin}
validateMessages={validateMessages}
autoComplete="off"
form={form}
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
<div className="auth-container">
<Divider>Log In</Divider>
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
<Button
loading={isGoogleLoading}
onClick={googleLogin}
disabled={isAnythingLoading}
>
<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
prefix={<MailOutlined />}
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}
/>
</Item>
<Item
name="password"
rules={[{ required: true }]}
rules={requiredRules}
hasFeedback
>
<Input.Password
placeholder="Password"
autoComplete="current-password"
prefix={<LockOutlined />}
disabled={isAnythingLoading}
/>
@ -121,39 +199,18 @@ const Login = () => {
style={{ width: '100%' }}
loading={isEmailLoading}
disabled={isAnythingLoading}
icon={<UnlockOutlined />}
>
Log In
Log in using password
</Button>
</Item>
</Form>
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
<Item>
<Button
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}>
<Link to={Routes.SIGN_UP}>
Sign Up
</Link>
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
Forgot password?
</Link>
</Button>
</Form>
</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 {
Form,
Input,
Button,
Divider,
Alert,
Space,
List,
} from 'antd';
import { UserOutlined } from '@ant-design/icons';
import {
UserOutlined,
MailOutlined,
LockOutlined,
} from '@ant-design/icons';
import validateMessages from './validateMessages';
import { useAuth } from '../../contexts/AuthContext';
import { restrictedPage } from './notifications';
import {
restrictedPage,
sendingEmailVerificationFailed,
emailVerificationSent,
profileUpdateSuccess,
profileUpdateFailed,
passwordUpdateSuccess,
passwordUpdateFailed,
} from './notifications';
import { Routes } from '../../routes';
import {
passwordRules,
requiredRules,
} from '../../utils/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 { currentUser } = useAuth();
const [formProfile] = Form.useForm();
const [formPassword] = Form.useForm();
const {
currentUser,
sendEmailVerification,
updateUsername,
updatePassword,
getSessions,
getLogs,
} = useAuth();
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(() => {
if (!currentUser) {
restrictedPage();
navigate(Routes.LOGIN);
if (currentUser) {
getSessions()
.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 (
<div className="small-container">
<Divider>Your Profile</Divider>
<Form
validateMessages={validateMessages}
form={form}
autoComplete="off"
>
<Item
name="username"
rules={[{ required: true }]}
hasFeedback
<>
<div className="auth-container">
{!currentUser?.emailVerification && (<>
<Divider>Email verification</Divider>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert message="Your email address is not verified!" type="error" showIcon />
<Button
type="primary"
style={{ width: '100%' }}
icon={<MailOutlined />}
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>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
<Item
name="username"
rules={requiredRules}
hasFeedback
>
Save
</Button>
</Item>
</Form>
</div>
<Input
prefix={<UserOutlined />}
placeholder="Username"
autoComplete="name"
/>
</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,
resetSuccessful,
} from './notifications';
import { emailRules } from '../../utils/form';
const { Item } = Form;
const ResetPassword = () => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const { resetPassword } = useAuth();
const { initResetPassword } = useAuth();
const navigate = useNavigate();
const onFinish = async ({ email }: { form: any, email: string }) => {
setIsLoading(true);
try {
await resetPassword(email);
await initResetPassword(email);
resetSuccessful();
navigate(Routes.LOGIN);
} catch (error) {
@ -41,23 +42,23 @@ const ResetPassword = () => {
};
return (
<div className="small-container">
<div className="auth-container">
<Divider>Reset password</Divider>
<Form
initialValues={{ remember: true }}
onFinish={onFinish}
validateMessages={validateMessages}
autoComplete="off"
form={form}
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
rules={emailRules}
hasFeedback
>
<Input
prefix={<MailOutlined />}
placeholder="Email"
autoComplete="email"
/>
</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 {
Form,
Input,
Button,
Divider,
Space,
} from 'antd';
import {
MailOutlined,
LockOutlined,
UserOutlined,
GoogleOutlined,
GithubOutlined,
FacebookOutlined,
UserAddOutlined,
} from '@ant-design/icons';
import {
Link,
@ -19,85 +29,142 @@ import validateMessages from './validateMessages';
import {
emailNotVerified,
signUpFailed,
magicLinkSent,
signUpSuccessful,
} from './notifications';
import {
emailRules,
passwordRules,
requiredRules,
} from '../../utils/form';
const { Item } = Form;
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
const SignUp = () => {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const { signUp } = useAuth();
const [formMagicLink] = Form.useForm();
const [formEmail] = Form.useForm();
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 isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
const onFinish = async ({ email, password }: { email: string, password: string }) => {
setIsLoading(true);
const googleLogin = useCallback(async () => {
setIsGoogleLoading(true);
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();
emailNotVerified();
if (!user.emailVerification) {
emailNotVerified();
}
navigate(Routes.HUB);
} catch (error) {
form.resetFields();
console.warn(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 (
<div className="small-container">
<div className="auth-container">
<Divider>Sign Up</Divider>
<Form
onFinish={onFinish}
validateMessages={validateMessages}
autoComplete="off"
form={form}
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
<Button
loading={isGoogleLoading}
onClick={googleLogin}
disabled={isAnythingLoading}
>
<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
prefix={<MailOutlined />}
placeholder="Email"
/>
</Item>
<Item
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 />}
id="email-magic-link"
autoComplete="email"
disabled={isAnythingLoading}
/>
</Item>
<Item>
@ -105,15 +172,64 @@ const SignUp = () => {
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isLoading}
loading={isMagicLinkLoading}
disabled={isAnythingLoading}
icon={<MailOutlined />}
>
Sign Up
Send me a Magic Link
</Button>
</Item>
</Form>
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
Log In
</Link>
<Form
onFinish={emailSignUp}
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>
);
};

View File

@ -13,6 +13,12 @@ const emailNotVerified = () => notification.warning({
...baseOptions,
});
const magicLinkSent = () => notification.success({
message: 'Check your email',
description: 'Magic link sent!',
...baseOptions,
});
const signUpSuccessful = () => notification.success({
message: 'Sign Up successful',
description: 'Welcome on board!',
@ -22,6 +28,7 @@ const signUpSuccessful = () => notification.success({
const signUpFailed = (err: Error) => notification.error({
message: 'Failed to create an account',
description: err.message,
...baseOptions,
});
const logInSuccessful = () => notification.success({
@ -33,6 +40,7 @@ const logInSuccessful = () => notification.success({
const logInFailed = (err: Error) => notification.error({
message: 'Failed to log in',
description: err.message,
...baseOptions,
});
const restrictedPage = () => notification.error({
@ -50,10 +58,11 @@ const logOutSuccessful = () => notification.success({
const logOutFailed = (err: Error) => notification.error({
message: 'Log out failed',
description: err.message,
...baseOptions,
});
const resetSuccessful = () => notification.success({
message: 'Password reset successful',
message: 'Password reset initiated',
description: 'Check your email!',
...baseOptions,
});
@ -61,10 +70,72 @@ const resetSuccessful = () => notification.success({
const resetFailed = (err: Error) => notification.error({
message: 'Password reset failed',
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 {
emailNotVerified,
magicLinkSent,
signUpSuccessful,
signUpFailed,
logInSuccessful,
@ -74,4 +145,14 @@ export {
logOutFailed,
resetSuccessful,
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_DIAGNOSE = '/t/:tuneId/diagnose',
UPLOAD = '/upload',
UPLOAD_WITH_TUNE_ID = '/upload/:tuneId',
LOGIN = '/auth/login',
LOGOUT = '/auth/logout',
PROFILE = '/auth/profile',
SIGN_UP = '/auth/sign-up',
FORGOT_PASSWORD = '/auth/forgot-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: {},
details: {} as any,
},
tuneData: {},
tuneData: {} as any,
logs: [],
config: {} as any,
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 {
readme?: string | null;
@ -17,15 +21,37 @@ export interface TuneDataDetails {
}
export interface TuneDbData {
id?: string,
userUid?: string;
createdAt?: Date | Timestamp | string;
updatedAt?: Date | Timestamp | string;
isPublished?: boolean;
isListed?: boolean;
tuneFile?: string | null;
logFiles?: string[];
toothLogFiles?: string[];
customIniFile?: string | null;
details?: TuneDataDetails;
userId: string;
tuneId: string;
signature: string;
tuneFileId?: string | null;
logFileIds?: string[];
toothLogFileIds?: string[];
customIniFileId?: string | null;
vehicleName: string | null;
engineMake: string | null;
engineCode: string | null;
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,
TuneWithDetails,
} from '@hyper-tuner/types';
import { TuneDbData } from './dbData';
import { TuneDbDocument } from './dbData';
export interface ConfigState extends Config {}
export interface TuneState extends TuneWithDetails {}
export interface TuneDataState extends TuneDbData {}
export interface TuneDataState extends TuneDbDocument {}
export interface LogsState extends Logs {}

View File

@ -9,17 +9,26 @@ import {
onProgress as onProgressType,
} from './http';
import TuneParser from './tune/TuneParser';
import { TuneDbData } from '../types/dbData';
import { TuneDbDocument } from '../types/dbData';
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');
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getFile, getINIFile } = useServerStorage();
const { getFileForDownload, getINIFile } = useServerStorage();
const started = new Date();
const tuneRaw = getFile(tuneData.tuneFile!);
const tuneRaw = await getFileForDownload(tuneData.tuneFileId!, bucketId);
const tuneParser = new TuneParser()
.parse(pako.inflate(new Uint8Array(await tuneRaw)));
.parse(pako.inflate(new Uint8Array(tuneRaw)));
if (!tuneParser.isValid()) {
console.error('Invalid tune');
@ -29,7 +38,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
}
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 config = new INI(buff).parse().getResults();
@ -45,7 +54,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
config.constants.pages[0].data.divider = divider;
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: 'tune/load', payload: tune });

View File

@ -8,10 +8,10 @@ export enum Colors {
BLUE = '#2fe3ff',
GREY = '#334455',
// dark theme
ACCENT = '#1e88ea',
TEXT = '#ddd',
MAIN = '#222629',
// dark theme - keep this in sync with: src/themes/dark.less and common.less
ACCENT = '#2F49D1',
TEXT = '#CECECE',
MAIN = '#1E1E1E',
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 sentryDsn = import.meta.env.VITE_SENTRY_DSN;
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 colorHsl = (min: number, max: number, value: number): HslType => {
const saturation = 60;
const lightness = 40;
const coldDeg = 220;
const saturation = 80;
const lightness = 45;
const coldDeg = 225;
const hotDeg = 0;
let hue = remap(value, min, max, coldDeg, hotDeg);

View File

@ -20,8 +20,14 @@ class TuneParser {
const raw = (new TextDecoder()).decode(buffer);
const xml = (new DOMParser()).parseFromString(raw, 'text/xml');
const xmlPages = xml.getElementsByTagName('page');
const bibliography = xml.getElementsByTagName('bibliography')[0].attributes as any;
const versionInfo = xml.getElementsByTagName('versionInfo')[0].attributes as any;
const bibliography = xml.getElementsByTagName('bibliography')[0]?.attributes as any;
const versionInfo = xml.getElementsByTagName('versionInfo')[0]?.attributes as any;
if (!xmlPages || !bibliography || !versionInfo) {
this.isTuneValid = false;
return this;
}
this.tune.details = {
author: bibliography.author.value,
@ -64,7 +70,7 @@ class TuneParser {
this.isTuneValid = true;
}
if (this.tune.details.signature.match(/^speeduino \d+$/) === null) {
if (this.isSignatureSupported()) {
this.isTuneValid = false;
}
@ -78,6 +84,10 @@ class TuneParser {
isValid(): boolean {
return this.isTuneValid;
}
private isSignatureSupported(): boolean {
return this.tune.details.signature.match(/^speeduino \d+$/) === null;
}
}
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
export const generateShareUrl = (tuneId: string) => `${import.meta.env.VITE_WEB_URL}/#/t/${tuneId}`;
import { fetchEnv } from './env';
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,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"incremental": false,
"noUncheckedIndexedAccess": false
},
"include": [
"src"

View File

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