Migrate to Appwrite (#657)
This commit is contained in:
parent
66c2bbb869
commit
9275c01d53
16
.env
16
.env
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,7 +1,9 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"cSpell.words": [
|
||||
"Appwrite",
|
||||
"kbar",
|
||||
"prefs",
|
||||
"vite",
|
||||
"vitejs"
|
||||
]
|
||||
|
|
|
@ -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>
|
||||
```
|
||||
|
|
|
@ -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 -->
|
||||
|
|
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
|
@ -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 |
|
@ -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"
|
||||
}
|
||||
|
|
70
src/App.tsx
70
src/App.tsx
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 />}>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -61,7 +61,7 @@ const Curve = ({
|
|||
value: (_self, val) => `${val.toLocaleString()}${yUnits}`,
|
||||
points: { show: true },
|
||||
stroke: Colors.ACCENT,
|
||||
width: 2,
|
||||
width: 3,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
|
|
|
@ -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) => {
|
||||
const subMenuItems: ItemType[] = Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
|
||||
if (subMenuName === 'std_separator') {
|
||||
return <Menu.Divider key={buildUrl(navigation.tuneId!, menuName, subMenuName)} />;
|
||||
return { type: 'divider' };
|
||||
}
|
||||
|
||||
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subMenu = types[menuName].subMenus[subMenuName];
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@primary-color: #126ec3;
|
||||
@primary-color: #2F49D1;
|
||||
@text-light: #fff;
|
||||
|
||||
@border-radius-base: 6px;
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 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 };
|
||||
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
data.forEach((tuneSnapshot) => {
|
||||
temp.push(tuneSnapshot.data());
|
||||
});
|
||||
|
||||
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(),
|
||||
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);
|
||||
});
|
||||
}, [listTunes]);
|
||||
}, 300);
|
||||
|
||||
const debounceLoadData = useCallback((value: string) => loadData(value), [loadData]);
|
||||
|
||||
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>
|
||||
|
|
|
@ -23,7 +23,7 @@ const mapStateToProps = (state: AppState) => ({
|
|||
});
|
||||
|
||||
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
||||
if (!tuneData.details) {
|
||||
if (!tuneData?.vehicleName) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
|
@ -32,86 +32,93 @@ 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 >
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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 />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
<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={emailLogin}
|
||||
onFinish={magicLinkLogin}
|
||||
validateMessages={validateMessages}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
>
|
||||
<Item
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
hasFeedback
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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) {
|
||||
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;
|
||||
}
|
||||
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
}, [currentUser, fetchLogs, getLogs, getSessions, navigate]);
|
||||
|
||||
return (
|
||||
<div className="small-container">
|
||||
<>
|
||||
<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={form}
|
||||
autoComplete="off"
|
||||
form={formProfile}
|
||||
onFinish={onUpdateProfile}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
value: currentUser?.name,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
value: currentUser?.email,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Item
|
||||
name="username"
|
||||
rules={[{ required: true }]}
|
||||
rules={requiredRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="Username" />
|
||||
<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}
|
||||
>
|
||||
Save
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
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>
|
||||
<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={onFinish}
|
||||
onFinish={magicLinkLogin}
|
||||
validateMessages={validateMessages}
|
||||
autoComplete="off"
|
||||
form={form}
|
||||
>
|
||||
<Item
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
hasFeedback
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ const initialState: AppState = {
|
|||
constants: {},
|
||||
details: {} as any,
|
||||
},
|
||||
tuneData: {},
|
||||
tuneData: {} as any,
|
||||
logs: [],
|
||||
config: {} as any,
|
||||
ui: {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
}];
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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}`;
|
||||
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"incremental": false,
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
|
|
@ -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 },
|
||||
|
|
Loading…
Reference in New Issue