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_ENVIRONMENT=development
|
||||||
VITE_WEB_URL=http://localhost:3000
|
VITE_WEB_URL=http://localhost:3000
|
||||||
VITE_SENTRY_DSN=
|
VITE_SENTRY_DSN=
|
||||||
VITE_FIREBASE_APP_SENTRY_DSN=
|
|
||||||
VITE_FIREBASE_API_KEY=
|
# TODO: remove this later
|
||||||
VITE_FIREBASE_AUTH_DOMAIN=
|
|
||||||
VITE_FIREBASE_PROJECT_ID=
|
|
||||||
VITE_FIREBASE_STORAGE_BUCKET=
|
|
||||||
VITE_FIREBASE_MESSAGING_SENDER_ID=
|
|
||||||
VITE_FIREBASE_APP_ID=
|
|
||||||
VITE_FIREBASE_MEASUREMENT_ID=
|
|
||||||
VITE_CDN_URL=
|
VITE_CDN_URL=
|
||||||
|
|
||||||
|
VITE_APPWRITE_ENDPOINT=
|
||||||
|
VITE_APPWRITE_PROJECT_ID=
|
||||||
|
VITE_APPWRITE_DATABASE_ID=
|
||||||
|
VITE_APPWRITE_COLLECTION_ID_PUBLIC_TUNES=
|
||||||
|
VITE_APPWRITE_COLLECTION_ID_USERS_BUCKETS=
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
# To get started with Dependabot version updates, you'll need to specify which
|
|
||||||
# package ecosystems to update and where the package manifests are located.
|
|
||||||
# Please see the documentation for all configuration options:
|
|
||||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
registries:
|
registries:
|
||||||
npm-github:
|
npm-github:
|
||||||
|
@ -10,8 +5,8 @@ registries:
|
||||||
url: https://npm.pkg.github.com
|
url: https://npm.pkg.github.com
|
||||||
token: ${{ secrets.NPM_GITHUB_PAT }}
|
token: ${{ secrets.NPM_GITHUB_PAT }}
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm" # See documentation for possible values
|
- package-ecosystem: "npm"
|
||||||
directory: "/" # Location of package manifests
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 20
|
open-pull-requests-limit: 20
|
||||||
|
|
|
@ -12,7 +12,6 @@ node_modules
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
|
@ -24,8 +23,17 @@ yarn-error.log*
|
||||||
|
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# custom ts builds
|
# Editor directories and files
|
||||||
/src/**/*.js
|
# .vscode/*
|
||||||
|
# !.vscode/extensions.json
|
||||||
|
# !.vscode/settings.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
# rollup-plugin-visualizer generated files
|
# rollup-plugin-visualizer generated files
|
||||||
stats.html
|
stats.html
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
|
"Appwrite",
|
||||||
"kbar",
|
"kbar",
|
||||||
|
"prefs",
|
||||||
"vite",
|
"vite",
|
||||||
"vitejs"
|
"vitejs"
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,13 +4,7 @@ This guide will help you set up this project.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [Node](https://nodejs.org/) 16.x.x (Node Version Manager: [nvm](https://github.com/nvm-sh/nvm))
|
- Node Version Manager: [nvm](https://github.com/nvm-sh/nvm)
|
||||||
- [Firebase](https://console.firebase.google.com/)
|
|
||||||
- Authentication
|
|
||||||
- Storage
|
|
||||||
- Firestore Database
|
|
||||||
- [Firebase CLI](https://firebase.google.com/docs/cli)
|
|
||||||
- [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) (`brew install --cask google-cloud-sdk`)
|
|
||||||
|
|
||||||
### Setup local environment variables
|
### Setup local environment variables
|
||||||
|
|
||||||
|
@ -34,6 +28,12 @@ Private token can be assign to ENV when running `npm install` in the same shell:
|
||||||
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
|
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Setup correct Node.js version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nvm use
|
||||||
|
```
|
||||||
|
|
||||||
### Install dependencies and run in development mode
|
### Install dependencies and run in development mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
@ -43,19 +43,3 @@ npm install
|
||||||
# run development server
|
# run development server
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Firebase
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
|
|
||||||
Authenticate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gcloud auth login
|
|
||||||
```
|
|
||||||
|
|
||||||
Set up CORS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gsutil cors set firebase/cors.json gs://<YOUR-BUCKET>
|
|
||||||
```
|
|
||||||
|
|
|
@ -3,10 +3,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/icons/icon.ico" />
|
<link rel="icon" href="/icons/icon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#222629" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/icon.png" />
|
<link rel="apple-touch-icon" href="/icons/icon.png" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="preconnect" href="https://apis.google.com" crossorigin>
|
<link rel="preconnect" href="https://apis.google.com" crossorigin>
|
||||||
<meta property="og:title" content="HyperTuner Cloud">
|
<meta property="og:title" content="HyperTuner Cloud">
|
||||||
<meta name="twitter:image:alt" content="HyperTuner Cloud">
|
<meta name="twitter:image:alt" content="HyperTuner Cloud">
|
||||||
|
@ -19,7 +18,7 @@
|
||||||
<meta name="description" content="HyperTuner - Share your tunes and logs" />
|
<meta name="description" content="HyperTuner - Share your tunes and logs" />
|
||||||
<title>HyperTuner Cloud</title>
|
<title>HyperTuner Cloud</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="background-color: #222629;">
|
<body style="background-color: #191C1E">
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Vite entrypoint -->
|
<!-- Vite entrypoint -->
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
|
@ -15,52 +15,55 @@
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"lint": "tsc && eslint --max-warnings=0 src",
|
"lint": "tsc && eslint --max-warnings=0 src",
|
||||||
"lint:fix": "eslint --fix src"
|
"lint:fix": "eslint --fix src",
|
||||||
|
"stats:bundle": "npm run build && open stats.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^1.7.2",
|
"@hyper-tuner/ini": "^0.3.1",
|
||||||
"@sentry/react": "^6.18.0",
|
"@hyper-tuner/types": "^0.3.3",
|
||||||
"@sentry/tracing": "^6.18.0",
|
"@reduxjs/toolkit": "^1.8.3",
|
||||||
"@hyper-tuner/ini": "^0.3.0",
|
"@sentry/react": "^7.7.0",
|
||||||
"@hyper-tuner/types": "^0.3.0",
|
"@sentry/tracing": "^7.7.0",
|
||||||
"antd": "^4.18.8",
|
"antd": "^4.21.6",
|
||||||
"firebase": "^9.6.7",
|
"appwrite": "^9.0.1",
|
||||||
"kbar": "^0.1.0-beta.34",
|
"kbar": "^0.1.0-beta.36",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"mlg-converter": "^0.5.1",
|
"mlg-converter": "^0.5.1",
|
||||||
"nanoid": "^3.3.1",
|
"nanoid": "^4.0.0",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
"react": "^18.1.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-markdown": "^8.0.0",
|
"react-markdown": "^8.0.3",
|
||||||
"react-perfect-scrollbar": "^1.5.8",
|
"react-perfect-scrollbar": "^1.5.8",
|
||||||
"react-redux": "^8.0.1",
|
"react-redux": "^8.0.2",
|
||||||
"react-router-dom": "^6.2.1",
|
"react-router-dom": "^6.3.0",
|
||||||
"uplot": "^1.6.19",
|
"uplot": "^1.6.22",
|
||||||
"uplot-react": "^1.1.1",
|
"uplot-react": "^1.1.1",
|
||||||
"vite": "^2.8.4"
|
"vite": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hyper-tuner/eslint-config": "^0.1.5",
|
"@hyper-tuner/eslint-config": "^0.1.6",
|
||||||
"@types/node": "^17.0.19",
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
"@types/pako": "^1.0.3",
|
"@types/node": "^18.0.5",
|
||||||
"@types/react": "^18.0.3",
|
"@types/pako": "^2.0.0",
|
||||||
"@types/react-dom": "^18.0.3",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-redux": "^7.1.22",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/react-redux": "^7.1.24",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.12.1",
|
"@typescript-eslint/eslint-plugin": "^5.30.6",
|
||||||
"@typescript-eslint/parser": "^5.21.0",
|
"@typescript-eslint/parser": "^5.30.6",
|
||||||
"@vitejs/plugin-react": "^1.2.0",
|
"@vitejs/plugin-react": "^2.0.0",
|
||||||
"eslint": "^8.14.0",
|
"eslint": "^8.20.0",
|
||||||
"eslint-plugin-flowtype": "^8.0.3",
|
"eslint-plugin-flowtype": "^8.0.3",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
"eslint-plugin-jsx-a11y": "^6.6.0",
|
||||||
"eslint-plugin-modules-newline": "^0.0.6",
|
"eslint-plugin-modules-newline": "^0.0.6",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"eslint-plugin-react-hooks": "^4.5.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"less": "^4.1.2",
|
"less": "^4.1.3",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.7.1",
|
||||||
"rollup-plugin-visualizer": "^5.6.0",
|
"rollup-plugin-visualizer": "^5.7.1",
|
||||||
"typescript": "^4.5.5"
|
"typescript": "^4.7.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 194 KiB |
Binary file not shown.
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 244 KiB |
|
@ -17,14 +17,14 @@
|
||||||
"screenshots" : [
|
"screenshots" : [
|
||||||
{
|
{
|
||||||
"src": "/img/screen1.png",
|
"src": "/img/screen1.png",
|
||||||
"sizes": "1920x1109",
|
"sizes": "1920x1194",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"platform": "wide",
|
"platform": "wide",
|
||||||
"label": "VE Table with command palette"
|
"label": "VE Table with command palette"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/img/screen2.png",
|
"src": "/img/screen2.png",
|
||||||
"sizes": "1920x1111",
|
"sizes": "1920x1194",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"platform": "wide",
|
"platform": "wide",
|
||||||
"label": "Log viewer"
|
"label": "Log viewer"
|
||||||
|
@ -32,6 +32,6 @@
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#222629",
|
"theme_color": "#191C1E",
|
||||||
"background_color": "#222629"
|
"background_color": "#191C1E"
|
||||||
}
|
}
|
||||||
|
|
70
src/App.tsx
70
src/App.tsx
|
@ -2,6 +2,7 @@ import {
|
||||||
Routes as ReactRoutes,
|
Routes as ReactRoutes,
|
||||||
Route,
|
Route,
|
||||||
useMatch,
|
useMatch,
|
||||||
|
useNavigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Layout,
|
Layout,
|
||||||
|
@ -14,6 +15,7 @@ import {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import TopBar from './components/TopBar';
|
import TopBar from './components/TopBar';
|
||||||
import StatusBar from './components/StatusBar';
|
import StatusBar from './components/StatusBar';
|
||||||
|
@ -25,6 +27,7 @@ import Loader from './components/Loader';
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
NavigationState,
|
NavigationState,
|
||||||
|
TuneDataState,
|
||||||
UIState,
|
UIState,
|
||||||
} from './types/state';
|
} from './types/state';
|
||||||
import useDb from './hooks/useDb';
|
import useDb from './hooks/useDb';
|
||||||
|
@ -32,7 +35,7 @@ import Info from './pages/Info';
|
||||||
import Hub from './pages/Hub';
|
import Hub from './pages/Hub';
|
||||||
|
|
||||||
import 'react-perfect-scrollbar/dist/css/styles.css';
|
import 'react-perfect-scrollbar/dist/css/styles.css';
|
||||||
import './App.less';
|
import './css/App.less';
|
||||||
|
|
||||||
// TODO: fix this
|
// TODO: fix this
|
||||||
// lazy loading this component causes a weird Curve canvas scaling
|
// lazy loading this component causes a weird Curve canvas scaling
|
||||||
|
@ -40,11 +43,14 @@ import './App.less';
|
||||||
|
|
||||||
const Tune = lazy(() => import('./pages/Tune'));
|
const Tune = lazy(() => import('./pages/Tune'));
|
||||||
const Diagnose = lazy(() => import('./pages/Diagnose'));
|
const Diagnose = lazy(() => import('./pages/Diagnose'));
|
||||||
|
const Upload = lazy(() => import('./pages/Upload'));
|
||||||
const Login = lazy(() => import('./pages/auth/Login'));
|
const Login = lazy(() => import('./pages/auth/Login'));
|
||||||
const Profile = lazy(() => import('./pages/auth/Profile'));
|
const Profile = lazy(() => import('./pages/auth/Profile'));
|
||||||
const SignUp = lazy(() => import('./pages/auth/SignUp'));
|
const SignUp = lazy(() => import('./pages/auth/SignUp'));
|
||||||
const ResetPassword = lazy(() => import('./pages/auth/ResetPassword'));
|
const ResetPassword = lazy(() => import('./pages/auth/ResetPassword'));
|
||||||
const Upload = lazy(() => import('./pages/Upload'));
|
const MagicLinkConfirmation = lazy(() => import('./pages/auth/MagicLinkConfirmation'));
|
||||||
|
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
|
||||||
|
const ResetPasswordConfirmation = lazy(() => import('./pages/auth/ResetPasswordConfirmation'));
|
||||||
|
|
||||||
const { Content } = Layout;
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
@ -52,11 +58,32 @@ const mapStateToProps = (state: AppState) => ({
|
||||||
ui: state.ui,
|
ui: state.ui,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
navigation: state.navigation,
|
navigation: state.navigation,
|
||||||
|
tuneData: state.tuneData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) => {
|
const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: NavigationState, tuneData: TuneDataState }) => {
|
||||||
const margin = ui.sidebarCollapsed ? 80 : 250;
|
const margin = ui.sidebarCollapsed ? 80 : 250;
|
||||||
const { getTune } = useDb();
|
const { getTune } = useDb();
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
const redirectPage = searchParams.get('redirectPage');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { getBucketId } = useDb();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// TODO: refactor this
|
||||||
|
switch (redirectPage) {
|
||||||
|
case Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION:
|
||||||
|
window.location.href = `/#${Routes.MAGIC_LINK_CONFIRMATION}?${searchParams.toString()}`;
|
||||||
|
break;
|
||||||
|
case Routes.REDIRECT_PAGE_EMAIL_VERIFICATION:
|
||||||
|
window.location.href = `/#${Routes.EMAIL_VERIFICATION}?${searchParams.toString()}`;
|
||||||
|
break;
|
||||||
|
case Routes.REDIRECT_PAGE_RESET_PASSWORD:
|
||||||
|
window.location.href = `/#${Routes.RESET_PASSWORD_CONFIRMATION}?${searchParams.toString()}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// const [lastDialogPath, setLastDialogPath] = useState<string|null>();
|
// const [lastDialogPath, setLastDialogPath] = useState<string|null>();
|
||||||
// const lastDialogPath = storageGetSync('lastDialog');
|
// const lastDialogPath = storageGetSync('lastDialog');
|
||||||
|
@ -66,9 +93,25 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tuneId) {
|
if (tuneId) {
|
||||||
getTune(tuneId).then(async (tuneData) => {
|
// clear out last state
|
||||||
loadTune(tuneData);
|
if (tuneData && tuneId !== tuneData.tuneId) {
|
||||||
store.dispatch({ type: 'tuneData/load', payload: tuneData });
|
setIsLoading(true);
|
||||||
|
loadTune(null, '');
|
||||||
|
store.dispatch({ type: 'tuneData/load', payload: null });
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTune(tuneId).then(async (tune) => {
|
||||||
|
if (!tune) {
|
||||||
|
console.warn('Tune not found');
|
||||||
|
navigate(Routes.HUB);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBucketId(tune.userId).then((bucketId) => {
|
||||||
|
loadTune(tune!, bucketId);
|
||||||
|
});
|
||||||
|
store.dispatch({ type: 'tuneData/load', payload: tune });
|
||||||
});
|
});
|
||||||
|
|
||||||
store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
|
store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
|
||||||
|
@ -92,15 +135,17 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
|
||||||
<Layout style={{ marginLeft }}>
|
<Layout style={{ marginLeft }}>
|
||||||
<Layout className="app-content">
|
<Layout className="app-content">
|
||||||
<Content>
|
<Content>
|
||||||
<Suspense fallback={<Loader />}>
|
<Suspense fallback={<Loader />}>{element}</Suspense>
|
||||||
{element}
|
|
||||||
</Suspense>
|
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -111,11 +156,16 @@ const App = ({ ui, navigation }: { ui: UIState, navigation: NavigationState }) =
|
||||||
<Route path={`${Routes.TUNE_TUNE}/*`} element={<ContentFor marginLeft={margin} element={<Tune />} />} />
|
<Route path={`${Routes.TUNE_TUNE}/*`} element={<ContentFor marginLeft={margin} element={<Tune />} />} />
|
||||||
<Route path={Routes.TUNE_LOGS} element={<ContentFor marginLeft={margin} element={<Logs />} />} />
|
<Route path={Routes.TUNE_LOGS} element={<ContentFor marginLeft={margin} element={<Logs />} />} />
|
||||||
<Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} />
|
<Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} />
|
||||||
|
<Route path={`${Routes.UPLOAD}/*`} element={<ContentFor element={<Upload />} />} />
|
||||||
|
|
||||||
<Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} />
|
<Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} />
|
||||||
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
|
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
|
||||||
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
|
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
|
||||||
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
|
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
|
||||||
<Route path={Routes.UPLOAD} element={<ContentFor element={<Upload />} />} />
|
|
||||||
|
<Route path={Routes.MAGIC_LINK_CONFIRMATION} element={<ContentFor element={<MagicLinkConfirmation />} />} />
|
||||||
|
<Route path={Routes.EMAIL_VERIFICATION} element={<ContentFor element={<EmailVerification />} />} />
|
||||||
|
<Route path={Routes.RESET_PASSWORD_CONFIRMATION} element={<ContentFor element={<ResetPasswordConfirmation />} />} />
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
|
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -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 {
|
interface CommandPaletteProps {
|
||||||
config: ConfigType;
|
config: ConfigType | null;
|
||||||
tune: TuneType;
|
tune: TuneType | null;
|
||||||
navigation: NavigationState;
|
navigation: NavigationState;
|
||||||
// eslint-disable-next-line react/no-unused-prop-types
|
// eslint-disable-next-line react/no-unused-prop-types
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
@ -289,14 +289,14 @@ const ActionsProvider = (props: CommandPaletteProps) => {
|
||||||
}, [navigate, navigation.tuneId]);
|
}, [navigate, navigation.tuneId]);
|
||||||
|
|
||||||
const getActions = () => {
|
const getActions = () => {
|
||||||
if (Object.keys(tune.constants).length) {
|
if (tune?.constants && Object.keys(tune.constants).length) {
|
||||||
return generateActions(config.menus);
|
return generateActions(config!.menus);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
useRegisterActions(getActions(), [tune.constants]);
|
useRegisterActions(getActions(), [tune?.constants]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
@ -52,7 +52,7 @@ const StatusBar = ({ tune }: { tune: TuneState }) => (
|
||||||
<Footer className="app-status-bar">
|
<Footer className="app-status-bar">
|
||||||
<Row>
|
<Row>
|
||||||
<Col span={20}>
|
<Col span={20}>
|
||||||
{tune.details.author && <Firmware tune={tune} />}
|
{tune?.details?.author && <Firmware tune={tune} />}
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={4} style={{ textAlign: 'right' }}>
|
<Col span={4} style={{ textAlign: 'right' }}>
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -166,6 +166,28 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
|
||||||
return list.length ? list : null;
|
return list.length ? list : null;
|
||||||
}, [lg, sm]);
|
}, [lg, sm]);
|
||||||
|
|
||||||
|
const userMenuItems = useMemo(() => currentUser ? [{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: 'Profile',
|
||||||
|
onClick: () => navigate(Routes.PROFILE),
|
||||||
|
}, {
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: 'Logout',
|
||||||
|
onClick: logoutClick,
|
||||||
|
}] : [{
|
||||||
|
key: 'login',
|
||||||
|
icon: <LoginOutlined />,
|
||||||
|
label: 'Login',
|
||||||
|
onClick: () => navigate(Routes.LOGIN),
|
||||||
|
}, {
|
||||||
|
key: 'sign-up',
|
||||||
|
icon: <UserAddOutlined />,
|
||||||
|
label: 'Sign Up',
|
||||||
|
onClick: () => navigate(Routes.SIGN_UP),
|
||||||
|
}], [currentUser, logoutClick, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header className="app-top-bar">
|
<Header className="app-top-bar">
|
||||||
<Row>
|
<Row>
|
||||||
|
@ -225,30 +247,8 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
overlay={
|
overlay={<Menu items={userMenuItems} />}
|
||||||
<Menu>
|
placement="bottomRight"
|
||||||
{currentUser ? (
|
|
||||||
<>
|
|
||||||
<Menu.Item key="profile" icon={<UserOutlined />}>
|
|
||||||
<Link to={Routes.PROFILE}>Profile</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={logoutClick}>
|
|
||||||
Logout
|
|
||||||
</Menu.Item>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Menu.Item key="login" icon={<LoginOutlined />}>
|
|
||||||
<Link to={Routes.LOGIN}>Login</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item key="sign-up" icon={<UserAddOutlined />}>
|
|
||||||
<Link to={Routes.SIGN_UP}>Sign Up</Link>
|
|
||||||
</Menu.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
}
|
|
||||||
placement="bottom"
|
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
>
|
>
|
||||||
<Button icon={<UserOutlined />}>
|
<Button icon={<UserOutlined />}>
|
||||||
|
|
|
@ -96,7 +96,7 @@ const Dialog = ({
|
||||||
name: string,
|
name: string,
|
||||||
url: string,
|
url: string,
|
||||||
}) => {
|
}) => {
|
||||||
const isDataReady = Object.keys(tune.constants).length && Object.keys(config.constants).length;
|
const isDataReady = tune && config && Object.keys(tune.constants).length && Object.keys(config.constants).length;
|
||||||
const { storageSet } = useBrowserStorage();
|
const { storageSet } = useBrowserStorage();
|
||||||
const { findConstantOnPage } = useConfig(config);
|
const { findConstantOnPage } = useConfig(config);
|
||||||
const [panelsComponents, setPanelsComponents] = useState<any[]>([]);
|
const [panelsComponents, setPanelsComponents] = useState<any[]>([]);
|
||||||
|
@ -138,7 +138,7 @@ const Dialog = ({
|
||||||
yData={parseXy(y.value as string)}
|
yData={parseXy(y.value as string)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [config.help, findConstantOnPage, tune.constants]);
|
}, [config?.help, findConstantOnPage, tune?.constants]);
|
||||||
|
|
||||||
const renderTable = useCallback((table: TableType | RenderedPanel) => {
|
const renderTable = useCallback((table: TableType | RenderedPanel) => {
|
||||||
const x = tune.constants[table.xBins[0]];
|
const x = tune.constants[table.xBins[0]];
|
||||||
|
@ -157,7 +157,7 @@ const Dialog = ({
|
||||||
yUnits={y.units as string}
|
yUnits={y.units as string}
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}, [tune.constants]);
|
}, [tune?.constants]);
|
||||||
|
|
||||||
const calculateSpan = (type: PanelTypes, dialogsCount: number) => {
|
const calculateSpan = (type: PanelTypes, dialogsCount: number) => {
|
||||||
let xxl = 24;
|
let xxl = 24;
|
||||||
|
@ -221,7 +221,7 @@ const Dialog = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.dialogs) {
|
if (config?.dialogs) {
|
||||||
resolveDialogs(config.dialogs, name);
|
resolveDialogs(config.dialogs, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,7 +340,7 @@ const Dialog = ({
|
||||||
{panel.type === PanelTypes.TABLE && renderTable(panel)}
|
{panel.type === PanelTypes.TABLE && renderTable(panel)}
|
||||||
</Col>
|
</Col>
|
||||||
);
|
);
|
||||||
}), [config, findConstantOnPage, panels, renderCurve, renderTable, tune.constants]);
|
}), [config, findConstantOnPage, panels, renderCurve, renderTable, tune?.constants]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storageSet('lastDialog', url);
|
storageSet('lastDialog', url);
|
||||||
|
|
|
@ -61,7 +61,7 @@ const Curve = ({
|
||||||
value: (_self, val) => `${val.toLocaleString()}${yUnits}`,
|
value: (_self, val) => `${val.toLocaleString()}${yUnits}`,
|
||||||
points: { show: true },
|
points: { show: true },
|
||||||
stroke: Colors.ACCENT,
|
stroke: Colors.ACCENT,
|
||||||
width: 2,
|
width: 3,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
axes: [
|
axes: [
|
||||||
|
|
|
@ -2,11 +2,12 @@ import {
|
||||||
Layout,
|
Layout,
|
||||||
Menu,
|
Menu,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { ItemType } from 'antd/lib/menu/hooks/useItems';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
generatePath,
|
generatePath,
|
||||||
Link,
|
|
||||||
PathMatch,
|
PathMatch,
|
||||||
|
useNavigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||||
import {
|
import {
|
||||||
|
@ -29,7 +30,6 @@ import {
|
||||||
} from '../../types/state';
|
} from '../../types/state';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
const { SubMenu } = Menu;
|
|
||||||
|
|
||||||
export const SKIP_MENUS = [
|
export const SKIP_MENUS = [
|
||||||
'help',
|
'help',
|
||||||
|
@ -59,8 +59,8 @@ const mapStateToProps = (state: AppState) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface SideBarProps {
|
interface SideBarProps {
|
||||||
config: ConfigType;
|
config: ConfigType | null;
|
||||||
tune: TuneType;
|
tune: TuneType | null;
|
||||||
ui: UIState;
|
ui: UIState;
|
||||||
navigation: NavigationState;
|
navigation: NavigationState;
|
||||||
matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'>;
|
matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'>;
|
||||||
|
@ -75,50 +75,49 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
|
||||||
collapsed: ui.sidebarCollapsed,
|
collapsed: ui.sidebarCollapsed,
|
||||||
onCollapse: (collapsed: boolean) => store.dispatch({ type: 'ui/sidebarCollapsed', payload: collapsed }),
|
onCollapse: (collapsed: boolean) => store.dispatch({ type: 'ui/sidebarCollapsed', payload: collapsed }),
|
||||||
} as any;
|
} as any;
|
||||||
const [menus, setMenus] = useState<any[]>([]);
|
const [menus, setMenus] = useState<ItemType[]>([]);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const menusList = useCallback((types: MenusType) => (
|
const menusList = useCallback((types: MenusType): ItemType[] => (
|
||||||
Object.keys(types).map((menuName: string) => {
|
Object.keys(types).map((menuName: string) => {
|
||||||
if (SKIP_MENUS.includes(menuName)) {
|
if (SKIP_MENUS.includes(menuName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const subMenuItems: ItemType[] = Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
|
||||||
<SubMenu
|
if (subMenuName === 'std_separator') {
|
||||||
key={`/${menuName}`}
|
return { type: 'divider' };
|
||||||
icon={<Icon name={menuName} />}
|
}
|
||||||
title={types[menuName].title}
|
|
||||||
onTitleClick={() => store.dispatch({ type: 'ui/sidebarCollapsed', payload: false })}
|
|
||||||
>
|
|
||||||
{Object.keys(types[menuName].subMenus).map((subMenuName: string) => {
|
|
||||||
if (subMenuName === 'std_separator') {
|
|
||||||
return <Menu.Divider key={buildUrl(navigation.tuneId!, menuName, subMenuName)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
|
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const subMenu = types[menuName].subMenus[subMenuName];
|
|
||||||
|
|
||||||
return (<Menu.Item
|
const subMenu = types[menuName].subMenus[subMenuName];
|
||||||
key={buildUrl(navigation.tuneId!, menuName, subMenuName)}
|
|
||||||
icon={<Icon name={subMenuName} />}
|
return {
|
||||||
>
|
key: buildUrl(navigation.tuneId!, menuName, subMenuName),
|
||||||
<Link to={buildUrl(navigation.tuneId!, menuName, subMenuName)}>
|
icon: <Icon name={subMenuName} />,
|
||||||
{subMenu.title}
|
label: subMenu.title,
|
||||||
</Link>
|
onClick: () => navigate(buildUrl(navigation.tuneId!, menuName, subMenuName)),
|
||||||
</Menu.Item>);
|
};
|
||||||
})}
|
});
|
||||||
</SubMenu>
|
|
||||||
);
|
return {
|
||||||
|
key: `/${menuName}`,
|
||||||
|
icon: <Icon name={menuName} />,
|
||||||
|
label: types[menuName].title,
|
||||||
|
onClick: () => ui.sidebarCollapsed && store.dispatch({ type: 'ui/sidebarCollapsed', payload: false }),
|
||||||
|
children: subMenuItems,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
), [navigation.tuneId]);
|
), [navigate, navigation.tuneId, ui.sidebarCollapsed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(tune.constants).length) {
|
if (tune && config && Object.keys(tune.constants).length) {
|
||||||
setMenus(menusList(config.menus));
|
setMenus(menusList(config.menus));
|
||||||
}
|
}
|
||||||
}, [config.menus, menusList, tune.constants]);
|
}, [config, config?.menus, menusList, tune, tune?.constants]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sider {...siderProps} className="app-sidebar">
|
<Sider {...siderProps} className="app-sidebar">
|
||||||
|
@ -129,9 +128,8 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
|
||||||
mode="inline"
|
mode="inline"
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
key={matchedPath.pathname}
|
key={matchedPath.pathname}
|
||||||
>
|
items={menus}
|
||||||
{menus}
|
/>
|
||||||
</Menu>
|
|
||||||
</PerfectScrollbar>
|
</PerfectScrollbar>
|
||||||
</Sider>
|
</Sider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,15 +1,3 @@
|
||||||
import {
|
|
||||||
User,
|
|
||||||
UserCredential,
|
|
||||||
createUserWithEmailAndPassword,
|
|
||||||
signInWithEmailAndPassword,
|
|
||||||
sendEmailVerification,
|
|
||||||
signOut,
|
|
||||||
sendPasswordResetEmail,
|
|
||||||
GoogleAuthProvider,
|
|
||||||
GithubAuthProvider,
|
|
||||||
signInWithPopup,
|
|
||||||
} from 'firebase/auth';
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
@ -18,19 +6,118 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { auth } from '../firebase';
|
import {
|
||||||
|
account,
|
||||||
|
client,
|
||||||
|
} from '../appwrite';
|
||||||
|
import Loader from '../components/Loader';
|
||||||
|
import { Routes } from '../routes';
|
||||||
|
import {
|
||||||
|
buildFullUrl,
|
||||||
|
buildRedirectUrl,
|
||||||
|
} from '../utils/url';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
$id: string;
|
||||||
|
name: string;
|
||||||
|
registration: number;
|
||||||
|
status: boolean;
|
||||||
|
passwordUpdate: number;
|
||||||
|
email: string;
|
||||||
|
emailVerification: boolean;
|
||||||
|
prefs: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
$id: string;
|
||||||
|
userId: string;
|
||||||
|
expire: number;
|
||||||
|
provider: string;
|
||||||
|
providerUid: string;
|
||||||
|
providerAccessToken: string;
|
||||||
|
providerAccessTokenExpiry: number;
|
||||||
|
providerRefreshToken: string;
|
||||||
|
ip: string;
|
||||||
|
osCode: string;
|
||||||
|
osName: string;
|
||||||
|
osVersion: string;
|
||||||
|
clientType: string;
|
||||||
|
clientCode: string;
|
||||||
|
clientName: string;
|
||||||
|
clientVersion: string;
|
||||||
|
clientEngine: string;
|
||||||
|
clientEngineVersion: string;
|
||||||
|
deviceName: string;
|
||||||
|
deviceBrand: string;
|
||||||
|
deviceModel: string;
|
||||||
|
countryCode: string;
|
||||||
|
countryName: string;
|
||||||
|
current: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SessionList {
|
||||||
|
sessions: Session[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
event: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userName: string;
|
||||||
|
mode: string;
|
||||||
|
ip: string;
|
||||||
|
time: number;
|
||||||
|
osCode: string;
|
||||||
|
osName: string;
|
||||||
|
osVersion: string;
|
||||||
|
clientType: string;
|
||||||
|
clientCode: string;
|
||||||
|
clientName: string;
|
||||||
|
clientVersion: string;
|
||||||
|
clientEngine: string;
|
||||||
|
clientEngineVersion: string;
|
||||||
|
deviceName: string;
|
||||||
|
deviceBrand: string;
|
||||||
|
deviceModel: string;
|
||||||
|
countryCode: string;
|
||||||
|
countryName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LogList {
|
||||||
|
logs: Log[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthValue {
|
interface AuthValue {
|
||||||
currentUser: User | null,
|
currentUser: User | null,
|
||||||
signUp: (email: string, password: string) => Promise<void>,
|
signUp: (email: string, password: string, username: string) => Promise<User>,
|
||||||
login: (email: string, password: string) => Promise<UserCredential>,
|
login: (email: string, password: string) => Promise<User>,
|
||||||
|
sendMagicLink: (email: string) => Promise<void>,
|
||||||
|
confirmMagicLink: (userId: string, secret: string) => Promise<User>,
|
||||||
|
sendEmailVerification: () => Promise<void>,
|
||||||
|
confirmEmailVerification: (userId: string, secret: string) => Promise<void>,
|
||||||
|
confirmResetPassword: (userId: string, secret: string, password: string) => Promise<void>,
|
||||||
logout: () => Promise<void>,
|
logout: () => Promise<void>,
|
||||||
resetPassword: (email: string) => Promise<void>,
|
initResetPassword: (email: string) => Promise<void>,
|
||||||
googleAuth: () => Promise<void>,
|
googleAuth: () => Promise<void>,
|
||||||
githubAuth: () => Promise<void>,
|
githubAuth: () => Promise<void>,
|
||||||
refreshToken: () => Promise<string> | undefined,
|
facebookAuth: () => Promise<void>,
|
||||||
|
updateUsername: (username: string) => Promise<void>,
|
||||||
|
updatePassword: (password: string, oldPassword: string) => Promise<void>,
|
||||||
|
getSessions: () => Promise<SessionList>,
|
||||||
|
getLogs: () => Promise<LogList>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OAUTH_REDIRECT_URL = buildFullUrl();
|
||||||
|
const MAGIC_LINK_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION);
|
||||||
|
const EMAIL_VERIFICATION_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_EMAIL_VERIFICATION);
|
||||||
|
const RESET_PASSWORD_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_RESET_PASSWORD);
|
||||||
|
|
||||||
|
const GOOGLE_SCOPES = ['https://www.googleapis.com/auth/userinfo.email'];
|
||||||
|
const GITHUB_SCOPES = ['user:email'];
|
||||||
|
const FACEBOOK_SCOPES = ['email'];
|
||||||
|
|
||||||
const AuthContext = createContext<AuthValue | null>(null);
|
const AuthContext = createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
const useAuth = () => useContext<AuthValue>(AuthContext as any);
|
const useAuth = () => useContext<AuthValue>(AuthContext as any);
|
||||||
|
@ -42,28 +129,145 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
const value = useMemo(() => ({
|
||||||
currentUser,
|
currentUser,
|
||||||
signUp: (email: string, password: string) => createUserWithEmailAndPassword(auth, email, password)
|
signUp: async (email: string, password: string, username: string) => {
|
||||||
.then((userCredential) => sendEmailVerification(userCredential.user)),
|
try {
|
||||||
login: (email: string, password: string) => signInWithEmailAndPassword(auth, email, password),
|
await account.create('unique()', email, password, username);
|
||||||
logout: () => signOut(auth),
|
await account.createEmailSession(email, password);
|
||||||
resetPassword: (email: string) => sendPasswordResetEmail(auth, email),
|
const user = await account.get();
|
||||||
|
setCurrentUser(user);
|
||||||
|
return Promise.resolve(user);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login: async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
await account.createEmailSession(email, password);
|
||||||
|
const user = await account.get();
|
||||||
|
setCurrentUser(user);
|
||||||
|
return Promise.resolve(user);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendMagicLink: async (email: string) => {
|
||||||
|
try {
|
||||||
|
await account.createMagicURLSession('unique()', email, MAGIC_LINK_REDIRECT_URL);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmMagicLink: async (userId: string, secret: string) => {
|
||||||
|
try {
|
||||||
|
await account.updateMagicURLSession(userId, secret);
|
||||||
|
const user = await account.get();
|
||||||
|
setCurrentUser(user);
|
||||||
|
return Promise.resolve(user);
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendEmailVerification: async () => {
|
||||||
|
try {
|
||||||
|
await account.createVerification(EMAIL_VERIFICATION_REDIRECT_URL);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmEmailVerification: async (userId: string, secret: string) => {
|
||||||
|
try {
|
||||||
|
await account.updateVerification(userId, secret);
|
||||||
|
const user = await account.get();
|
||||||
|
setCurrentUser(user);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmResetPassword: async (userId: string, secret: string, password: string) => {
|
||||||
|
try {
|
||||||
|
await account.updateRecovery(userId, secret, password, password);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await account.deleteSession('current');
|
||||||
|
setCurrentUser(null);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initResetPassword: async (email: string) => {
|
||||||
|
try {
|
||||||
|
await account.createRecovery(email, RESET_PASSWORD_REDIRECT_URL);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
googleAuth: async () => {
|
googleAuth: async () => {
|
||||||
const provider = new GoogleAuthProvider().addScope('https://www.googleapis.com/auth/userinfo.email');
|
account.createOAuth2Session(
|
||||||
const credentials = await signInWithPopup(auth, provider);
|
'google',
|
||||||
setCurrentUser(credentials.user);
|
OAUTH_REDIRECT_URL,
|
||||||
|
OAUTH_REDIRECT_URL,
|
||||||
|
GOOGLE_SCOPES,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
githubAuth: async () => {
|
githubAuth: async () => {
|
||||||
const provider = new GithubAuthProvider().addScope('user:email');
|
account.createOAuth2Session(
|
||||||
const credentials = await signInWithPopup(auth, provider);
|
'github',
|
||||||
setCurrentUser(credentials.user);
|
OAUTH_REDIRECT_URL,
|
||||||
|
OAUTH_REDIRECT_URL,
|
||||||
|
GITHUB_SCOPES,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
refreshToken: () => auth.currentUser?.getIdToken(true),
|
facebookAuth: async () => {
|
||||||
|
account.createOAuth2Session(
|
||||||
|
'facebook',
|
||||||
|
OAUTH_REDIRECT_URL,
|
||||||
|
OAUTH_REDIRECT_URL,
|
||||||
|
FACEBOOK_SCOPES,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
updateUsername: async (username: string) => {
|
||||||
|
try {
|
||||||
|
await account.updateName(username);
|
||||||
|
const user = await account.get();
|
||||||
|
setCurrentUser(user);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatePassword: async (password: string, oldPassword: string) => {
|
||||||
|
try {
|
||||||
|
await account.updatePassword(password, oldPassword);
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (error) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getSessions: () => account.getSessions(),
|
||||||
|
getLogs: () => account.getLogs(),
|
||||||
}), [currentUser]);
|
}), [currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = auth.onAuthStateChanged((user) => {
|
account.get().then((user) => {
|
||||||
|
console.info('Logged as:', user.name || 'Unknown');
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
}).catch(() => {
|
||||||
|
console.info('User not logged in');
|
||||||
|
}).finally(() => setIsLoading(false));
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribe('account', (event) => {
|
||||||
|
console.info('Account event', event);
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
@ -71,7 +275,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={value}>
|
<AuthContext.Provider value={value}>
|
||||||
{!isLoading && children}
|
{isLoading ? <Loader /> : children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,14 +4,7 @@
|
||||||
@import './themes/dark.less';
|
@import './themes/dark.less';
|
||||||
@import './themes/common.less';
|
@import './themes/common.less';
|
||||||
@import './themes/ant.less';
|
@import './themes/ant.less';
|
||||||
|
@import './overrides.less';
|
||||||
:root {
|
|
||||||
--background: @component-background;
|
|
||||||
--foreground: @text;
|
|
||||||
--a1: @main;
|
|
||||||
--border: @border-color-split;
|
|
||||||
--shadow: @shadow-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -36,13 +29,6 @@ html, body {
|
||||||
z-index: @bars-z-index;
|
z-index: @bars-z-index;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
height: calc(100vh - @layout-header-height - @layout-footer-height);
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-status-bar {
|
.app-status-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
@ -58,12 +44,25 @@ html, body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
height: calc(100vh - @layout-header-height - @layout-footer-height);
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
height: calc(100vh - @layout-header-height - @layout-footer-height);
|
height: calc(100vh - @layout-header-height - @layout-footer-height);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tabpane {
|
||||||
|
height: calc(100vh - @layout-header-height - @layout-footer-height - @layout-trigger-height - @tabs-nav-height);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.small-container,
|
.small-container,
|
||||||
.large-container,
|
.large-container,
|
||||||
.auth-container {
|
.auth-container {
|
||||||
|
@ -83,25 +82,10 @@ html, body {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-tabs-tabpane {
|
|
||||||
height: calc(100vh - @layout-header-height - @layout-footer-height - @layout-trigger-height - @tabs-nav-height);
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-checkbox-wrapper {
|
.ant-checkbox-wrapper {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-upload-list-picture-card
|
|
||||||
.ant-upload-list-item-actions
|
|
||||||
.anticon-delete,
|
|
||||||
.ant-upload-list-picture-card
|
|
||||||
.ant-upload-list-item-actions
|
|
||||||
.anticon-eye {
|
|
||||||
color: @text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
|
|
|
@ -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;
|
@text-light: #fff;
|
||||||
|
|
||||||
@border-radius-base: 6px;
|
@border-radius-base: 6px;
|
|
@ -1,9 +1,9 @@
|
||||||
// darker
|
// darker
|
||||||
@main: #222629;
|
@text: #CECECE;
|
||||||
@main-dark: #191C1E;
|
@main: #191C1E;
|
||||||
@main-light: #2E3338;
|
@main-dark: #1E1E1E;
|
||||||
|
@main-light: #252525;
|
||||||
@text: #ddd;
|
@main-darkest: #171717;
|
||||||
|
|
||||||
// lighter
|
// lighter
|
||||||
// @main: #272c30;
|
// @main: #272c30;
|
|
@ -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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useConfig = (config: ConfigType) => useMemo(() => ({
|
const useConfig = (config: ConfigType | null) => useMemo(() => ({
|
||||||
isConfigReady: !!config.constants,
|
isConfigReady: !!config?.constants,
|
||||||
findOutputChannel: (name: string) => findOutputChannel(config, name),
|
findOutputChannel: (name: string) => findOutputChannel(config!, name),
|
||||||
findConstantOnPage: (name: string) => findConstantOnPage(config, name),
|
findConstantOnPage: (name: string) => findConstantOnPage(config!, name),
|
||||||
findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config, label),
|
findDatalogNameByLabel: (label: string) => findDatalogNameByLabel(config!, label),
|
||||||
findDatalog: (name: string) => findDatalog(config, name),
|
findDatalog: (name: string) => findDatalog(config!, name),
|
||||||
}), [config]);
|
}), [config]);
|
||||||
|
|
||||||
export default useConfig;
|
export default useConfig;
|
||||||
|
|
|
@ -1,84 +1,126 @@
|
||||||
import { notification } from 'antd';
|
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
import {
|
import {
|
||||||
Timestamp,
|
Models,
|
||||||
doc,
|
Query,
|
||||||
getDoc,
|
} from 'appwrite';
|
||||||
setDoc,
|
import { database } from '../appwrite';
|
||||||
collection,
|
import {
|
||||||
where,
|
TuneDbData,
|
||||||
query,
|
UsersBucket,
|
||||||
getDocs,
|
TuneDbDataPartial,
|
||||||
QuerySnapshot,
|
TuneDbDocument,
|
||||||
orderBy,
|
} from '../types/dbData';
|
||||||
getFirestore,
|
import { databaseGenericError } from '../pages/auth/notifications';
|
||||||
} from 'firebase/firestore/lite';
|
import { fetchEnv } from '../utils/env';
|
||||||
import { TuneDbData } from '../types/dbData';
|
|
||||||
|
|
||||||
const TUNES_PATH = 'publicTunes';
|
const COLLECTION_ID_PUBLIC_TUNES = fetchEnv('VITE_APPWRITE_COLLECTION_ID_PUBLIC_TUNES');
|
||||||
|
const COLLECTION_ID_USERS_BUCKETS = fetchEnv('VITE_APPWRITE_COLLECTION_ID_USERS_BUCKETS');
|
||||||
const db = getFirestore();
|
|
||||||
|
|
||||||
const genericError = (error: Error) => notification.error({ message: 'Database Error', description: error.message });
|
|
||||||
|
|
||||||
const useDb = () => {
|
const useDb = () => {
|
||||||
const getTuneData = async (tuneId: string) => {
|
const updateTune = async (documentId: string, data: TuneDbDataPartial) => {
|
||||||
try {
|
try {
|
||||||
const tune = (await getDoc(doc(db, TUNES_PATH, tuneId))).data() as TuneDbData;
|
await database.updateDocument(COLLECTION_ID_PUBLIC_TUNES, documentId, data);
|
||||||
const processed = {
|
|
||||||
...tune,
|
|
||||||
createdAt: (tune?.createdAt as Timestamp)?.toDate().toISOString(),
|
|
||||||
updatedAt: (tune?.updatedAt as Timestamp)?.toDate().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return Promise.resolve(processed);
|
|
||||||
} catch (error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
console.error(error);
|
|
||||||
genericError(error as Error);
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const listTunesData = async () => {
|
|
||||||
try {
|
|
||||||
const tunesRef = collection(db, TUNES_PATH);
|
|
||||||
const q = query(
|
|
||||||
tunesRef,
|
|
||||||
where('isPublished', '==', true),
|
|
||||||
where('isListed', '==', true),
|
|
||||||
orderBy('createdAt', 'desc'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Promise.resolve(await getDocs(q));
|
|
||||||
} catch (error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
console.error(error);
|
|
||||||
genericError(error as Error);
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateData = async (tuneId: string, data: TuneDbData) => {
|
|
||||||
try {
|
|
||||||
await setDoc(doc(db, TUNES_PATH, tuneId), data, { merge: true });
|
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
genericError(error as Error);
|
databaseGenericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTune = async (data: TuneDbData) => {
|
||||||
|
try {
|
||||||
|
const tune = await database.createDocument(
|
||||||
|
COLLECTION_ID_PUBLIC_TUNES,
|
||||||
|
'unique()',
|
||||||
|
data,
|
||||||
|
['role:all'],
|
||||||
|
[`user:${data.userId}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve(tune);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
databaseGenericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTune = async (tuneId: string) => {
|
||||||
|
try {
|
||||||
|
const tune = await database.listDocuments(
|
||||||
|
COLLECTION_ID_PUBLIC_TUNES,
|
||||||
|
[Query.equal('tuneId', tuneId)],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve(tune.total > 0 ? tune.documents[0] as unknown as TuneDbDocument : null);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
databaseGenericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBucketId = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const buckets = await database.listDocuments(
|
||||||
|
COLLECTION_ID_USERS_BUCKETS,
|
||||||
|
[
|
||||||
|
Query.equal('userId', userId),
|
||||||
|
Query.equal('visibility', 'public'),
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (buckets.total === 0) {
|
||||||
|
throw new Error('No public bucket found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve((buckets.documents[0] as unknown as UsersBucket)!.bucketId);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
databaseGenericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchTunes = async (search?: string) => {
|
||||||
|
// TODO: add pagination
|
||||||
|
const limit = 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list: Models.DocumentList<TuneDbDocument> = await (
|
||||||
|
search
|
||||||
|
? database.listDocuments(COLLECTION_ID_PUBLIC_TUNES, [Query.search('textSearch', search)], limit)
|
||||||
|
: database.listDocuments(COLLECTION_ID_PUBLIC_TUNES, [], limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve(list);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
databaseGenericError(error as Error);
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateData: (tuneId: string, data: TuneDbData): Promise<void> => updateData(tuneId, data),
|
updateTune: (tuneId: string, data: TuneDbDataPartial): Promise<void> => updateTune(tuneId, data),
|
||||||
getTune: (tuneId: string): Promise<TuneDbData> => getTuneData(tuneId),
|
createTune: (data: TuneDbData): Promise<Models.Document> => createTune(data),
|
||||||
listTunes: (): Promise<QuerySnapshot<TuneDbData>> => listTunesData(),
|
getTune: (tuneId: string): Promise<TuneDbDocument | null> => getTune(tuneId),
|
||||||
|
searchTunes: (search?: string): Promise<Models.DocumentList<TuneDbDocument>> => searchTunes(search),
|
||||||
|
getBucketId: (userId: string): Promise<string> => getBucketId(userId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,46 +1,23 @@
|
||||||
import { notification } from 'antd';
|
import { notification } from 'antd';
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
import {
|
import { Models } from 'appwrite';
|
||||||
UploadTask,
|
import { storage } from '../appwrite';
|
||||||
ref,
|
import { fetchEnv } from '../utils/env';
|
||||||
getBytes,
|
|
||||||
deleteObject,
|
|
||||||
uploadBytesResumable,
|
|
||||||
getStorage,
|
|
||||||
} from 'firebase/storage';
|
|
||||||
|
|
||||||
const PUBLIC_PATH = 'public';
|
const PUBLIC_PATH = 'public';
|
||||||
const USERS_PATH = `${PUBLIC_PATH}/users`;
|
|
||||||
const INI_PATH = `${PUBLIC_PATH}/ini`;
|
const INI_PATH = `${PUBLIC_PATH}/ini`;
|
||||||
export const CDN_URL = import.meta.env.VITE_CDN_URL;
|
export const CDN_URL = fetchEnv('VITE_CDN_URL');
|
||||||
|
|
||||||
const storage = getStorage();
|
export type ServerFile = Models.File;
|
||||||
|
|
||||||
const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message });
|
const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message });
|
||||||
|
|
||||||
const fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
|
const fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
|
||||||
if (CDN_URL) {
|
const response = await fetch(`${CDN_URL}/${path}`);
|
||||||
const response = await fetch(`${CDN_URL}/${path}`);
|
return Promise.resolve(response.arrayBuffer());
|
||||||
return Promise.resolve(response.arrayBuffer());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(await getBytes(ref(storage, path)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useServerStorage = () => {
|
const useServerStorage = () => {
|
||||||
const getFile = async (path: string) => {
|
|
||||||
|
|
||||||
try {
|
|
||||||
return fetchFromServer(path);
|
|
||||||
} catch (error) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
console.error(error);
|
|
||||||
genericError(error as Error);
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getINIFile = async (signature: string) => {
|
const getINIFile = async (signature: string) => {
|
||||||
const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
|
const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
|
||||||
|
|
||||||
|
@ -52,7 +29,7 @@ const useServerStorage = () => {
|
||||||
|
|
||||||
notification.warning({
|
notification.warning({
|
||||||
message: 'INI not found',
|
message: 'INI not found',
|
||||||
description: `INI version: "${version}" not found. Trying base version: "${baseVersion}"!` ,
|
description: `INI version: "${version}" not found. Trying base version: "${baseVersion}"!`,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -63,7 +40,7 @@ const useServerStorage = () => {
|
||||||
|
|
||||||
notification.error({
|
notification.error({
|
||||||
message: 'INI not found',
|
message: 'INI not found',
|
||||||
description: `INI version: "${baseVersion}" not found. Try uploading custom INI file!` ,
|
description: `INI version: "${baseVersion}" not found. Try uploading custom INI file!`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,10 +48,9 @@ const useServerStorage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFile = async (path: string) => {
|
const removeFile = async (bucketId: string, fileId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteObject(ref(storage, path));
|
await storage.deleteFile(bucketId, fileId);
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
@ -85,20 +61,61 @@ const useServerStorage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = (path: string, file: File, data: Uint8Array) =>
|
const uploadFile = async (userId: string, bucketId: string, file: File) => {
|
||||||
uploadBytesResumable(ref(storage, path), data, {
|
try {
|
||||||
customMetadata: {
|
const createdFile = await storage.createFile(
|
||||||
name: file.name,
|
bucketId,
|
||||||
size: `${file.size}`,
|
'unique()',
|
||||||
},
|
file,
|
||||||
});
|
['role:all'],
|
||||||
|
[`user:${userId}`],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.resolve(createdFile);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
genericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFile = async (id: string, bucketId: string) => {
|
||||||
|
try {
|
||||||
|
const file = await storage.getFile(bucketId, id);
|
||||||
|
|
||||||
|
return Promise.resolve(file);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
genericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileForDownload = async (id: string, bucketId: string) => {
|
||||||
|
try {
|
||||||
|
const file = storage.getFileView(bucketId, id);
|
||||||
|
const response = await fetch(file.href);
|
||||||
|
|
||||||
|
return Promise.resolve(response.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
console.error(error);
|
||||||
|
genericError(error as Error);
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getFile: (path: string): Promise<ArrayBuffer> => getFile(path),
|
getFile: (id: string, bucketId: string): Promise<Models.File> => getFile(id, bucketId),
|
||||||
getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature),
|
getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature),
|
||||||
removeFile: (path: string): Promise<void> => removeFile(path),
|
removeFile: (bucketId: string, fileId: string): Promise<void> => removeFile(bucketId, fileId),
|
||||||
uploadFile: (path: string, file: File, data: Uint8Array): UploadTask => uploadFile(path, file, data),
|
getFileForDownload: (bucketId: string, fileId: string): Promise<ArrayBuffer> => getFileForDownload(bucketId, fileId),
|
||||||
basePathForFile: (userUuid: string, tuneId: string, fileName: string): string => `${USERS_PATH}/${userUuid}/tunes/${tuneId}/${fileName}`,
|
uploadFile: (userId: string, bucketId: string, file: File): Promise<ServerFile> => uploadFile(userId, bucketId, file),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -25,11 +25,12 @@ import {
|
||||||
generatePath,
|
generatePath,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import { Timestamp } from 'firebase/firestore/lite';
|
import debounce from 'lodash.debounce';
|
||||||
import useDb from '../hooks/useDb';
|
import useDb from '../hooks/useDb';
|
||||||
import { TuneDbData } from '../types/dbData';
|
import { TuneDbDocument } from '../types/dbData';
|
||||||
import { Routes } from '../routes';
|
import { Routes } from '../routes';
|
||||||
import { generateShareUrl } from '../utils/url';
|
import { buildFullUrl } from '../utils/url';
|
||||||
|
import { aspirationMapper } from '../utils/tune/mappers';
|
||||||
|
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
|
@ -47,17 +48,16 @@ const loadingCards = (
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||||
|
|
||||||
const Hub = () => {
|
const Hub = () => {
|
||||||
const { md } = useBreakpoint();
|
const { md } = useBreakpoint();
|
||||||
const { listTunes } = useDb();
|
const { searchTunes } = useDb();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tunes, setTunes] = useState<TuneDbData[]>([]);
|
const [dataSource, setDataSource] = useState<any>([]);
|
||||||
const [dataSource, setDataSource] = useState<any[]>([]);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const goToTune = (tuneId: string) => navigate(generatePath(Routes.TUNE_TUNE, { tuneId }));
|
|
||||||
|
|
||||||
const copyToClipboard = async (shareUrl: string) => {
|
const copyToClipboard = async (shareUrl: string) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
@ -66,44 +66,61 @@ const Hub = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = debounce(async (searchText?: string) => {
|
||||||
listTunes().then((data) => {
|
setIsLoading(true);
|
||||||
const temp: TuneDbData[] = [];
|
const list = await searchTunes(searchText);
|
||||||
|
// TODO: create `unpublishedTunes` collection for this
|
||||||
|
const filtered = list.documents.filter((tune) => !!tune.vehicleName);
|
||||||
|
setDataSource(filtered.map((tune) => ({
|
||||||
|
...tune,
|
||||||
|
key: tune.tuneId,
|
||||||
|
year: tune.year,
|
||||||
|
author: '?',
|
||||||
|
displacement: `${tune.displacement}l`,
|
||||||
|
aspiration: aspirationMapper[tune.aspiration],
|
||||||
|
updatedAt: new Date(tune.$updatedAt * 1000).toLocaleString(),
|
||||||
|
stars: 0,
|
||||||
|
})));
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
data.forEach((tuneSnapshot) => {
|
const debounceLoadData = useCallback((value: string) => loadData(value), [loadData]);
|
||||||
temp.push(tuneSnapshot.data());
|
|
||||||
});
|
|
||||||
|
|
||||||
setTunes(temp);
|
useEffect(() => {
|
||||||
setDataSource(temp.map((tune) => ({
|
loadData();
|
||||||
key: tune.id,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
tuneId: tune.id,
|
}, []); // TODO: fix this
|
||||||
make: tune.details!.make,
|
|
||||||
model: tune.details!.model,
|
|
||||||
year: tune.details!.year,
|
|
||||||
author: 'karniv00l',
|
|
||||||
publishedAt: new Date((tune.createdAt as Timestamp).seconds * 1000).toLocaleString(),
|
|
||||||
stars: 0,
|
|
||||||
})));
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [listTunes]);
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Make',
|
title: 'Vehicle name',
|
||||||
dataIndex: 'make',
|
dataIndex: 'vehicleName',
|
||||||
key: 'make',
|
key: 'vehicleName',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Model',
|
title: 'Engine make',
|
||||||
dataIndex: 'model',
|
dataIndex: 'engineMake',
|
||||||
key: 'model',
|
key: 'engineMake',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Year',
|
title: 'Engine code',
|
||||||
dataIndex: 'year',
|
dataIndex: 'engineCode',
|
||||||
key: 'year',
|
key: 'engineCode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Displacement',
|
||||||
|
dataIndex: 'displacement',
|
||||||
|
key: 'displacement',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cylinders',
|
||||||
|
dataIndex: 'cylindersCount',
|
||||||
|
key: 'cylindersCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Aspiration',
|
||||||
|
dataIndex: 'aspiration',
|
||||||
|
key: 'aspiration',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Author',
|
title: 'Author',
|
||||||
|
@ -112,8 +129,8 @@ const Hub = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Published',
|
title: 'Published',
|
||||||
dataIndex: 'publishedAt',
|
dataIndex: 'updatedAt',
|
||||||
key: 'publishedAt',
|
key: 'updatedAt',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: <StarOutlined />,
|
title: <StarOutlined />,
|
||||||
|
@ -125,45 +142,45 @@ const Hub = () => {
|
||||||
render: (tuneId: string) => (
|
render: (tuneId: string) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
|
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
|
||||||
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(generateShareUrl(tuneId))} />
|
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(buildFullUrl([tunePath(tuneId)]))} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button icon={<ArrowRightOutlined />} onClick={() => goToTune(tuneId)} />
|
<Button type="primary" icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tuneId))} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
key: 'tuneId',
|
key: 'tuneId',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // TODO: fix this
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="large-container">
|
<div className="large-container">
|
||||||
<Typography.Title>Hub</Typography.Title>
|
<Typography.Title>Hub</Typography.Title>
|
||||||
<Input style={{ marginBottom: 10, height: 40 }} placeholder="Search..." />
|
<Input
|
||||||
|
tabIndex={0}
|
||||||
|
style={{ marginBottom: 10, height: 40 }}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={({ target }) => debounceLoadData(target.value)}
|
||||||
|
/>
|
||||||
{md ?
|
{md ?
|
||||||
<Table dataSource={dataSource} columns={columns} loading={isLoading} />
|
<Table dataSource={dataSource} columns={columns} loading={isLoading} pagination={false} />
|
||||||
:
|
:
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{isLoading ? loadingCards : (
|
{isLoading ? loadingCards : (
|
||||||
tunes.map((tune) => (
|
dataSource.map((tune: TuneDbDocument) => (
|
||||||
<Col span={16} sm={8} key={tune.tuneFile}>
|
<Col span={16} sm={8} key={tune.tuneFile}>
|
||||||
<Card
|
<Card
|
||||||
title={tune.details!.model}
|
title={tune.vehicleName}
|
||||||
actions={[
|
actions={[
|
||||||
<Badge count={0} showZero size="small" color="gold">
|
<Badge count={0} showZero size="small" color="gold">
|
||||||
<StarOutlined />
|
<StarOutlined />
|
||||||
</Badge>,
|
</Badge>,
|
||||||
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
|
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
|
||||||
<CopyOutlined onClick={() => copyToClipboard(generateShareUrl(tune.id!))} />
|
<CopyOutlined onClick={() => copyToClipboard(buildFullUrl([tunePath(tune.id!)]))} />
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
<ArrowRightOutlined onClick={() => goToTune(tune.id!)} />,
|
<ArrowRightOutlined onClick={() => navigate(tunePath(tune.id!))} />,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Typography.Text ellipsis>
|
<Typography.Text ellipsis>
|
||||||
{tune.details!.make} {tune.details!.model} {tune.details!.year}
|
{tune.engineMake} {tune.engineCode} {tune.year}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -23,7 +23,7 @@ const mapStateToProps = (state: AppState) => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
||||||
if (!tuneData.details) {
|
if (!tuneData?.vehicleName) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,89 +32,96 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
||||||
<Divider>Details</Divider>
|
<Divider>Details</Divider>
|
||||||
<Form>
|
<Form>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col span={24} sm={24}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.make!} addonBefore="Make" />
|
<Input value={tuneData.vehicleName!} addonBefore="Vehicle name" />
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item>
|
|
||||||
<Input value={tuneData.details.model!} addonBefore="Model" />
|
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.year!} addonBefore="Year" style={{ width: '100%' }} />
|
<Input value={tuneData.engineMake!} addonBefore="Engine make" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.displacement!} addonBefore="Displacement" addonAfter="l" />
|
<Input value={tuneData.engineCode!} addonBefore="Engine code" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.hp!} addonBefore="HP" style={{ width: '100%' }} />
|
<Input value={tuneData.displacement!} addonBefore="Displacement" addonAfter="l" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.stockHp!} addonBefore="Stock HP" style={{ width: '100%' }} />
|
<Input value={tuneData.cylindersCount!} addonBefore="Cylinders" style={{ width: '100%' }} />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.engineCode!} addonBefore="Engine code" />
|
<Select placeholder="Aspiration" style={{ width: '100%' }} value={tuneData.aspiration}>
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item>
|
|
||||||
<Input value={tuneData.details.cylindersCount!} addonBefore="No of cylinders" style={{ width: '100%' }} />
|
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row {...rowProps}>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item>
|
|
||||||
<Select placeholder="Aspiration" style={{ width: '100%' }} value={tuneData.details.aspiration}>
|
|
||||||
<Select.Option value="na">Naturally aspirated</Select.Option>
|
<Select.Option value="na">Naturally aspirated</Select.Option>
|
||||||
<Select.Option value="turbocharger">Turbocharged</Select.Option>
|
<Select.Option value="turbocharged">Turbocharged</Select.Option>
|
||||||
<Select.Option value="supercharger">Supercharged</Select.Option>
|
<Select.Option value="supercharged">Supercharged</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.fuel!} addonBefore="Fuel" />
|
<Input value={tuneData.compression!} addonBefore="Compression" style={{ width: '100%' }} addonAfter=":1" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.injectorsSize!} addonBefore="Injectors size" addonAfter="cc" />
|
<Input value={tuneData.fuel!} addonBefore="Fuel" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item>
|
<Item>
|
||||||
<Input value={tuneData.details.coils!} addonBefore="Coils" />
|
<Input value={tuneData.ignition!} addonBefore="Ignition" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row {...rowProps}>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item>
|
||||||
|
<Input value={tuneData.injectorsSize!} addonBefore="Injectors size" addonAfter="cc" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item>
|
||||||
|
<Input value={tuneData.year!} addonBefore="Year" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row {...rowProps}>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item>
|
||||||
|
<Input value={tuneData.hp!} addonBefore="HP" style={{ width: '100%' }} />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item>
|
||||||
|
<Input value={tuneData.stockHp!} addonBefore="Stock HP" style={{ width: '100%' }} />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form>
|
</Form>
|
||||||
<Divider>README</Divider>
|
<Divider>README</Divider>
|
||||||
<div className="markdown-preview" style={{ height: '100%' }}>
|
<div className="markdown-preview" style={{ height: '100%' }}>
|
||||||
{tuneData.details?.readme && <ReactMarkdown>
|
{tuneData.readme && <ReactMarkdown>
|
||||||
{`${tuneData.details?.readme}`}
|
{`${tuneData.readme}`}
|
||||||
</ReactMarkdown>}
|
</ReactMarkdown>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -147,7 +147,7 @@ const Logs = ({
|
||||||
};
|
};
|
||||||
}).filter((val) => !!val);
|
}).filter((val) => !!val);
|
||||||
|
|
||||||
}, [config.datalog, findOutputChannel, isConfigReady]);
|
}, [config?.datalog, findOutputChannel, isConfigReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const worker = new MlgParserWorker();
|
const worker = new MlgParserWorker();
|
||||||
|
@ -178,7 +178,7 @@ const Logs = ({
|
||||||
store.dispatch({ type: 'logs/load', payload: data.result.records });
|
store.dispatch({ type: 'logs/load', payload: data.result.records });
|
||||||
break;
|
break;
|
||||||
case 'metrics':
|
case 'metrics':
|
||||||
console.log(`Log parsed in ${data.elapsed}ms`);
|
console.info(`Log parsed in ${data.elapsed}ms`);
|
||||||
setParseElapsed(msToTime(data.elapsed));
|
setParseElapsed(msToTime(data.elapsed));
|
||||||
setSamplesCount(data.records);
|
setSamplesCount(data.records);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
|
@ -213,7 +213,7 @@ const Logs = ({
|
||||||
worker.terminate();
|
worker.terminate();
|
||||||
window.removeEventListener('resize', calculateCanvasSize);
|
window.removeEventListener('resize', calculateCanvasSize);
|
||||||
};
|
};
|
||||||
}, [calculateCanvasSize, config.datalog, config.outputChannels, loadedLogs, ui.sidebarCollapsed]);
|
}, [calculateCanvasSize, config?.datalog, config?.outputChannels, loadedLogs, ui.sidebarCollapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -10,16 +10,20 @@ import Dialog from '../components/Tune/Dialog';
|
||||||
import SideBar from '../components/Tune/SideBar';
|
import SideBar from '../components/Tune/SideBar';
|
||||||
import { Routes } from '../routes';
|
import { Routes } from '../routes';
|
||||||
import useConfig from '../hooks/useConfig';
|
import useConfig from '../hooks/useConfig';
|
||||||
import { AppState } from '../types/state';
|
import {
|
||||||
|
AppState,
|
||||||
|
TuneState,
|
||||||
|
} from '../types/state';
|
||||||
import Loader from '../components/Loader';
|
import Loader from '../components/Loader';
|
||||||
|
|
||||||
const mapStateToProps = (state: AppState) => ({
|
const mapStateToProps = (state: AppState) => ({
|
||||||
navigation: state.navigation,
|
navigation: state.navigation,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
config: state.config,
|
config: state.config,
|
||||||
|
tune: state.tune,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Tune = ({ config }: { config: ConfigType }) => {
|
const Tune = ({ config, tune }: { config: ConfigType | null, tune: TuneState }) => {
|
||||||
const dialogMatch = useMatch(Routes.TUNE_DIALOG);
|
const dialogMatch = useMatch(Routes.TUNE_DIALOG);
|
||||||
const tuneRootMatch = useMatch(Routes.TUNE_TUNE);
|
const tuneRootMatch = useMatch(Routes.TUNE_TUNE);
|
||||||
// const { storageGetSync } = useBrowserStorage();
|
// const { storageGetSync } = useBrowserStorage();
|
||||||
|
@ -31,9 +35,9 @@ const Tune = ({ config }: { config: ConfigType }) => {
|
||||||
const tuneId = tunePathMatch?.params.tuneId;
|
const tuneId = tunePathMatch?.params.tuneId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConfigReady && tuneRootMatch) {
|
if (tune && config && tuneRootMatch) {
|
||||||
const firstCategory = Object.keys(config.menus)[0];
|
const firstCategory = Object.keys(config!.menus)[0];
|
||||||
const firstDialog = Object.keys(config.menus[firstCategory].subMenus)[0];
|
const firstDialog = Object.keys(config!.menus[firstCategory].subMenus)[0];
|
||||||
|
|
||||||
const firstDialogPath = generatePath(Routes.TUNE_DIALOG, {
|
const firstDialogPath = generatePath(Routes.TUNE_DIALOG, {
|
||||||
tuneId,
|
tuneId,
|
||||||
|
@ -43,9 +47,9 @@ const Tune = ({ config }: { config: ConfigType }) => {
|
||||||
|
|
||||||
navigate(firstDialogPath, { replace: true });
|
navigate(firstDialogPath, { replace: true });
|
||||||
}
|
}
|
||||||
}, [navigate, tuneRootMatch, isConfigReady, config.menus, tuneId]);
|
}, [navigate, tuneRootMatch, isConfigReady, config?.menus, tuneId, config, tune, dialogMatch]);
|
||||||
|
|
||||||
if (!isConfigReady || !dialogMatch) {
|
if (!tune || !config || !dialogMatch) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
@ -35,13 +34,11 @@ import { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||||
import { UploadFile } from 'antd/lib/upload/interface';
|
import { UploadFile } from 'antd/lib/upload/interface';
|
||||||
import {
|
import {
|
||||||
generatePath,
|
generatePath,
|
||||||
|
useMatch,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import {
|
import { nanoid } from 'nanoid';
|
||||||
customAlphabet,
|
|
||||||
nanoid,
|
|
||||||
} from 'nanoid';
|
|
||||||
import {
|
import {
|
||||||
emailNotVerified,
|
emailNotVerified,
|
||||||
restrictedPage,
|
restrictedPage,
|
||||||
|
@ -52,9 +49,15 @@ import TuneParser from '../utils/tune/TuneParser';
|
||||||
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
|
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
|
||||||
import LogParser from '../utils/logs/LogParser';
|
import LogParser from '../utils/logs/LogParser';
|
||||||
import useDb from '../hooks/useDb';
|
import useDb from '../hooks/useDb';
|
||||||
import useServerStorage from '../hooks/useServerStorage';
|
import useServerStorage, { ServerFile } from '../hooks/useServerStorage';
|
||||||
import { generateShareUrl } from '../utils/url';
|
import { buildFullUrl } from '../utils/url';
|
||||||
import Loader from '../components/Loader';
|
import Loader from '../components/Loader';
|
||||||
|
import {
|
||||||
|
requiredTextRules,
|
||||||
|
requiredRules,
|
||||||
|
} from '../utils/form';
|
||||||
|
import { TuneDbDataPartial } from '../types/dbData';
|
||||||
|
import { aspirationMapper } from '../utils/tune/mappers';
|
||||||
|
|
||||||
const { Item } = Form;
|
const { Item } = Form;
|
||||||
|
|
||||||
|
@ -65,22 +68,13 @@ enum MaxFiles {
|
||||||
CUSTOM_INI_FILES = 1,
|
CUSTOM_INI_FILES = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Path = string;
|
|
||||||
|
|
||||||
interface UploadedFile {
|
|
||||||
[autoUid: string]: Path;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadFileData {
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidationResult {
|
interface ValidationResult {
|
||||||
result: boolean;
|
result: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidateFile = (file: File) => Promise<ValidationResult>;
|
type ValidateFile = (file: File) => Promise<ValidationResult>;
|
||||||
|
type UploadDone = (fileCreated: ServerFile, file: File) => void;
|
||||||
|
|
||||||
const rowProps = { gutter: 10 };
|
const rowProps = { gutter: 10 };
|
||||||
const colProps = { span: 24, sm: 12 };
|
const colProps = { span: 24, sm: 12 };
|
||||||
|
@ -88,31 +82,48 @@ const colProps = { span: 24, sm: 12 };
|
||||||
const maxFileSizeMB = 50;
|
const maxFileSizeMB = 50;
|
||||||
const descriptionEditorHeight = 260;
|
const descriptionEditorHeight = 260;
|
||||||
const thisYear = (new Date()).getFullYear();
|
const thisYear = (new Date()).getFullYear();
|
||||||
const nanoidCustom = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
|
const generateTuneId = () => nanoid(10);
|
||||||
|
|
||||||
const tuneIcon = () => <ToolOutlined />;
|
const tuneIcon = () => <ToolOutlined />;
|
||||||
const logIcon = () => <FundOutlined />;
|
const logIcon = () => <FundOutlined />;
|
||||||
const toothLogIcon = () => <SettingOutlined />;
|
const toothLogIcon = () => <SettingOutlined />;
|
||||||
const iniIcon = () => <FileTextOutlined />;
|
const iniIcon = () => <FileTextOutlined />;
|
||||||
|
|
||||||
|
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||||
|
const tuneParser = new TuneParser();
|
||||||
|
|
||||||
const UploadPage = () => {
|
const UploadPage = () => {
|
||||||
|
const routeMatch = useMatch(Routes.UPLOAD_WITH_TUNE_ID);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isTuneLoading, setTuneIsLoading] = useState(true);
|
||||||
const [newTuneId, setNewTuneId] = useState<string>();
|
const [newTuneId, setNewTuneId] = useState<string>();
|
||||||
|
const [tuneDocumentId, setTuneDocumentId] = useState<string>();
|
||||||
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
|
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
|
||||||
const [shareUrl, setShareUrl] = useState<string>();
|
const [shareUrl, setShareUrl] = useState<string>();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
const [tuneFile, setTuneFile] = useState<UploadedFile | null | false>(null);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [logFiles, setLogFiles] = useState<UploadedFile>({});
|
|
||||||
const [toothLogFiles, setToothLogFiles] = useState<UploadedFile>({});
|
|
||||||
const [customIniFile, setCustomIniFile] = useState<UploadedFile | null>(null);
|
|
||||||
const hasNavigatorShare = navigator.share !== undefined;
|
|
||||||
const { currentUser, refreshToken } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { removeFile, uploadFile, basePathForFile } = useServerStorage();
|
|
||||||
const { updateData } = useDb();
|
|
||||||
const requiredRules = [{ required: true, message: 'This field is required!' }];
|
|
||||||
const [readme, setReadme] = useState('# My Tune\n\ndescription');
|
const [readme, setReadme] = useState('# My Tune\n\ndescription');
|
||||||
|
const [initialValues, setInitialValues] = useState<TuneDbDataPartial>({
|
||||||
|
readme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
|
||||||
|
const [defaultToothLogFilesList, setDefaultToothLogFilesList] = useState<UploadFile[]>([]);
|
||||||
|
const [defaultCustomIniFileList, setDefaultCustomIniFileList] = useState<UploadFile[]>([]);
|
||||||
|
|
||||||
|
const [tuneFileId, setTuneFileId] = useState<string | null>(null);
|
||||||
|
const [logFileIds, setLogFileIds] = useState<Map<string, string>>(new Map());
|
||||||
|
const [toothLogFileIds, setToothLogFileIds] = useState<Map<string, string>>(new Map());
|
||||||
|
const [customIniFileId, setCustomIniFileId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasNavigatorShare = navigator.share !== undefined;
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { removeFile, uploadFile, getFile } = useServerStorage();
|
||||||
|
const { createTune, getBucketId, updateTune, getTune } = useDb();
|
||||||
|
|
||||||
const noop = () => { };
|
const noop = () => { };
|
||||||
|
|
||||||
|
@ -130,29 +141,51 @@ const UploadPage = () => {
|
||||||
|
|
||||||
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
|
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
|
||||||
|
|
||||||
const publish = async (values: any) => {
|
const publishTune = async (values: any) => {
|
||||||
|
/* eslint-disable prefer-destructuring */
|
||||||
|
const vehicleName = values.vehicleName.trim();
|
||||||
|
const engineMake = values.engineMake.trim();
|
||||||
|
const engineCode = values.engineCode.trim();
|
||||||
|
const displacement = values.displacement;
|
||||||
|
const cylindersCount = values.cylindersCount;
|
||||||
|
const aspiration = values.aspiration.trim();
|
||||||
|
const compression = values.compression || null;
|
||||||
|
const fuel = values.fuel?.trim() || null;
|
||||||
|
const ignition = values.ignition?.trim() || null;
|
||||||
|
const injectorsSize = values.injectorsSize || null;
|
||||||
|
const year = values.year || null;
|
||||||
|
const hp = values.hp || null;
|
||||||
|
const stockHp = values.stockHp || null;
|
||||||
|
/* eslint-enable prefer-destructuring */
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await updateData(newTuneId!, {
|
await updateTune(tuneDocumentId!, {
|
||||||
id: newTuneId!,
|
vehicleName,
|
||||||
userUid: currentUser!.uid,
|
engineMake,
|
||||||
updatedAt: new Date(),
|
engineCode,
|
||||||
isPublished: true,
|
displacement,
|
||||||
isListed: values.isListed,
|
cylindersCount,
|
||||||
details: {
|
aspiration,
|
||||||
readme: readme || null,
|
compression,
|
||||||
make: values.make || null,
|
fuel,
|
||||||
model: values.model || null,
|
ignition,
|
||||||
displacement: values.displacement || null,
|
injectorsSize,
|
||||||
year: values.year || null,
|
year,
|
||||||
hp: values.hp || null,
|
hp,
|
||||||
stockHp: values.stockHp || null,
|
stockHp,
|
||||||
engineCode: values.engineCode || null,
|
readme: readme?.trim(),
|
||||||
cylindersCount: values.cylindersCount || null,
|
textSearch: [
|
||||||
aspiration: values.aspiration || null,
|
vehicleName,
|
||||||
fuel: values.fuel || null,
|
engineMake,
|
||||||
injectorsSize: values.injectorsSize || null,
|
engineCode,
|
||||||
coils: values.coils || null,
|
`${displacement}l`,
|
||||||
},
|
aspirationMapper[aspiration] || null,
|
||||||
|
fuel,
|
||||||
|
ignition,
|
||||||
|
year,
|
||||||
|
].filter((field) => field !== null && `${field}`.length > 1)
|
||||||
|
.join(' ')
|
||||||
|
.replace(/[^A-z\d ]/g, ''),
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setIsPublished(true);
|
setIsPublished(true);
|
||||||
|
@ -163,8 +196,14 @@ const UploadPage = () => {
|
||||||
message: `File should not be larger than ${maxFileSizeMB}MB!`,
|
message: `File should not be larger than ${maxFileSizeMB}MB!`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const upload = async (path: string, options: UploadRequestOption, done: Function, validate: ValidateFile) => {
|
const navigateToNewTuneId = useCallback(() => {
|
||||||
const { onError, onSuccess, onProgress, file } = options;
|
navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
|
||||||
|
tuneId: generateTuneId(),
|
||||||
|
}), { replace: true });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const upload = async (options: UploadRequestOption, done: UploadDone, validate: ValidateFile) => {
|
||||||
|
const { onError, onSuccess, file } = options;
|
||||||
|
|
||||||
const validation = await validate(file as File);
|
const validation = await validate(file as File);
|
||||||
if (!validation.result) {
|
if (!validation.result) {
|
||||||
|
@ -172,6 +211,7 @@ const UploadPage = () => {
|
||||||
const errorMessage = validation.message;
|
const errorMessage = validation.message;
|
||||||
notification.error({ message: errorName, description: errorMessage });
|
notification.error({ message: errorName, description: errorMessage });
|
||||||
onError!({ name: errorName, message: errorMessage });
|
onError!({ name: errorName, message: errorMessage });
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,96 +219,68 @@ const UploadPage = () => {
|
||||||
const pako = await import('pako');
|
const pako = await import('pako');
|
||||||
const buffer = await (file as File).arrayBuffer();
|
const buffer = await (file as File).arrayBuffer();
|
||||||
const compressed = pako.deflate(new Uint8Array(buffer));
|
const compressed = pako.deflate(new Uint8Array(buffer));
|
||||||
const uploadTask = uploadFile(path, file as File, compressed);
|
const bucketId = await getBucketId(currentUser!.$id);
|
||||||
|
const fileCreated: ServerFile = await uploadFile(currentUser!.$id, bucketId, new File([compressed], (file as File).name));
|
||||||
|
|
||||||
uploadTask.on(
|
done(fileCreated, file as File);
|
||||||
'state_changed',
|
onSuccess!(null);
|
||||||
(snap) => onProgress!({ percent: (snap.bytesTransferred / snap.totalBytes) * 100 }),
|
|
||||||
(err) => onError!(err),
|
|
||||||
() => {
|
|
||||||
onSuccess!(file);
|
|
||||||
if (done) done();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
notification.error({ message: 'Upload error', description: (error as Error).message });
|
notification.error({ message: 'Upload error', description: (error as Error).message });
|
||||||
onError!(error as Error);
|
onError!(error as Error);
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tuneFileData = () => ({
|
|
||||||
path: basePathForFile(currentUser!.uid, newTuneId!, `tune/${nanoid()}.msq.gz`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const logFileData = (file: UploadFile) => {
|
|
||||||
const { name } = file;
|
|
||||||
const extension = name.split('.').pop();
|
|
||||||
return {
|
|
||||||
path: basePathForFile(currentUser!.uid, newTuneId!, `logs/${nanoid()}.${extension}.gz`),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toothLogFilesData = () => ({
|
|
||||||
path: basePathForFile(currentUser!.uid, newTuneId!, `tooth-logs/${nanoid()}.csv.gz`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const customIniFileData = () => ({
|
|
||||||
path: basePathForFile(currentUser!.uid, newTuneId!, `ini/${nanoid()}.ini.gz`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadTune = async (options: UploadRequestOption) => {
|
const uploadTune = async (options: UploadRequestOption) => {
|
||||||
setShareUrl(generateShareUrl(newTuneId!));
|
upload(options, async (fileCreated: ServerFile, file: File) => {
|
||||||
|
const { signature } = tuneParser.parse(await file.arrayBuffer()).getTune().details;
|
||||||
|
|
||||||
const { path } = (options.data as unknown as UploadFileData);
|
if (tuneDocumentId) {
|
||||||
const tune: UploadedFile = {};
|
await updateTune(tuneDocumentId, {
|
||||||
tune[(options.file as UploadFile).uid] = path;
|
signature,
|
||||||
|
tuneFileId: fileCreated.$id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const document = await createTune({
|
||||||
|
userId: currentUser!.$id,
|
||||||
|
tuneId: newTuneId!,
|
||||||
|
signature,
|
||||||
|
tuneFileId: fileCreated.$id,
|
||||||
|
vehicleName: '',
|
||||||
|
displacement: 0,
|
||||||
|
cylindersCount: 0,
|
||||||
|
engineMake: '',
|
||||||
|
engineCode: '',
|
||||||
|
aspiration: 'na',
|
||||||
|
readme: '',
|
||||||
|
});
|
||||||
|
setTuneDocumentId(document.$id);
|
||||||
|
}
|
||||||
|
|
||||||
upload(path, options, () => {
|
setTuneFileId(fileCreated.$id);
|
||||||
// this is `create` for firebase
|
|
||||||
// initialize data
|
|
||||||
updateData(newTuneId!, {
|
|
||||||
id: newTuneId!,
|
|
||||||
userUid: currentUser!.uid,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
isPublished: false,
|
|
||||||
isListed: true,
|
|
||||||
details: {},
|
|
||||||
tuneFile: path,
|
|
||||||
});
|
|
||||||
}, async (file) => {
|
}, async (file) => {
|
||||||
const { result, message } = await validateSize(file);
|
const { result, message } = await validateSize(file);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setTuneFile(false);
|
|
||||||
return { result, message };
|
return { result, message };
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = (new TuneParser()).parse(await file.arrayBuffer()).isValid();
|
|
||||||
if (!valid) {
|
|
||||||
setTuneFile(false);
|
|
||||||
} else {
|
|
||||||
setTuneFile(tune);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: valid,
|
result: tuneParser.parse(await file.arrayBuffer()).isValid(),
|
||||||
message: 'Tune file is not valid!',
|
message: 'Tune file is not valid!',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadLogs = async (options: UploadRequestOption) => {
|
const uploadLogs = async (options: UploadRequestOption) => {
|
||||||
const { path } = (options.data as unknown as UploadFileData);
|
upload(options, async (fileCreated) => {
|
||||||
const tune: UploadedFile = {};
|
const newValues = new Map(logFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
|
||||||
const uuid = (options.file as UploadFile).uid;
|
await updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
|
||||||
tune[uuid] = path;
|
setLogFileIds(newValues);
|
||||||
const newValues = { ...logFiles, ...tune };
|
|
||||||
upload(path, options, () => {
|
|
||||||
updateData(newTuneId!, { logFiles: Object.values(newValues) });
|
|
||||||
}, async (file) => {
|
}, async (file) => {
|
||||||
const { result, message } = await validateSize(file);
|
const { result, message } = await validateSize(file);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
@ -292,10 +304,6 @@ const UploadPage = () => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
setLogFiles(newValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: valid,
|
result: valid,
|
||||||
message: 'Log file is empty or not valid!',
|
message: 'Log file is empty or not valid!',
|
||||||
|
@ -304,12 +312,10 @@ const UploadPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadToothLogs = async (options: UploadRequestOption) => {
|
const uploadToothLogs = async (options: UploadRequestOption) => {
|
||||||
const { path } = (options.data as unknown as UploadFileData);
|
upload(options, async (fileCreated) => {
|
||||||
const tune: UploadedFile = {};
|
const newValues = new Map(toothLogFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
|
||||||
tune[(options.file as UploadFile).uid] = path;
|
await updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
|
||||||
const newValues = { ...toothLogFiles, ...tune };
|
setToothLogFileIds(newValues);
|
||||||
upload(path, options, () => {
|
|
||||||
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
|
|
||||||
}, async (file) => {
|
}, async (file) => {
|
||||||
const { result, message } = await validateSize(file);
|
const { result, message } = await validateSize(file);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
@ -317,25 +323,18 @@ const UploadPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = new TriggerLogsParser(await file.arrayBuffer());
|
const parser = new TriggerLogsParser(await file.arrayBuffer());
|
||||||
const valid = parser.isComposite() || parser.isTooth();
|
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
setToothLogFiles(newValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: valid,
|
result: parser.isComposite() || parser.isTooth(),
|
||||||
message: 'Tooth logs file is empty or not valid!',
|
message: 'Tooth logs file is empty or not valid!',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadCustomIni = async (options: UploadRequestOption) => {
|
const uploadCustomIni = async (options: UploadRequestOption) => {
|
||||||
const { path } = (options.data as unknown as UploadFileData);
|
upload(options, async (fileCreated) => {
|
||||||
const tune: UploadedFile = {};
|
await updateTune(tuneDocumentId!, { customIniFileId: fileCreated.$id });
|
||||||
tune[(options.file as UploadFile).uid] = path;
|
setCustomIniFileId(fileCreated.$id);
|
||||||
upload(path, options, () => {
|
|
||||||
updateData(newTuneId!, { customIniFile: path });
|
|
||||||
}, async (file) => {
|
}, async (file) => {
|
||||||
const { result, message } = await validateSize(file);
|
const { result, message } = await validateSize(file);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
@ -352,10 +351,6 @@ const UploadPage = () => {
|
||||||
validationMessage = (error as Error).message;
|
validationMessage = (error as Error).message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
|
||||||
setCustomIniFile(tune);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result: valid,
|
result: valid,
|
||||||
message: validationMessage,
|
message: validationMessage,
|
||||||
|
@ -363,44 +358,99 @@ const UploadPage = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTuneFile = async (file: UploadFile) => {
|
const removeFileFromStorage = async (fileId: string) => {
|
||||||
if (tuneFile) {
|
await removeFile(await getBucketId(currentUser!.$id), fileId);
|
||||||
removeFile(tuneFile[file.uid]);
|
};
|
||||||
}
|
|
||||||
setTuneFile(null);
|
const removeTuneFile = async () => {
|
||||||
updateData(newTuneId!, { tuneFile: null });
|
await removeFileFromStorage(tuneFileId!);
|
||||||
|
await updateTune(tuneDocumentId!, { tuneFileId: null });
|
||||||
|
setTuneFileId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLogFile = async (file: UploadFile) => {
|
const removeLogFile = async (file: UploadFile) => {
|
||||||
const { uid } = file;
|
await removeFileFromStorage(logFileIds.get(file.uid)!);
|
||||||
if (logFiles[file.uid]) {
|
logFileIds.delete(file.uid);
|
||||||
removeFile(logFiles[file.uid]);
|
const newValues = new Map(logFileIds);
|
||||||
}
|
setLogFileIds(newValues);
|
||||||
const newValues = { ...logFiles };
|
updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
|
||||||
delete newValues[uid];
|
|
||||||
setLogFiles(newValues);
|
|
||||||
updateData(newTuneId!, { logFiles: Object.values(newValues) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeToothLogFile = async (file: UploadFile) => {
|
const removeToothLogFile = async (file: UploadFile) => {
|
||||||
const { uid } = file;
|
await removeFileFromStorage(toothLogFileIds.get(file.uid)!);
|
||||||
if (toothLogFiles[file.uid]) {
|
toothLogFileIds.delete(file.uid);
|
||||||
removeFile(toothLogFiles[file.uid]);
|
const newValues = new Map(toothLogFileIds);
|
||||||
}
|
setToothLogFileIds(newValues);
|
||||||
const newValues = { ...toothLogFiles };
|
updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
|
||||||
delete newValues[uid];
|
|
||||||
setToothLogFiles(newValues);
|
|
||||||
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeCustomIniFile = async (file: UploadFile) => {
|
const removeCustomIniFile = async (file: UploadFile) => {
|
||||||
if (customIniFile) {
|
await removeFileFromStorage(customIniFileId!);
|
||||||
removeFile(customIniFile![file.uid]);
|
await updateTune(tuneDocumentId!, { customIniFileId: null });
|
||||||
}
|
setCustomIniFileId(null);
|
||||||
setCustomIniFile(null);
|
|
||||||
updateData(newTuneId!, { customIniFile: null });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadExistingTune = useCallback(async (currentTuneId: string) => {
|
||||||
|
setNewTuneId(currentTuneId);
|
||||||
|
console.info('Using tuneId:', currentTuneId);
|
||||||
|
|
||||||
|
const existingTune = await getTune(currentTuneId);
|
||||||
|
if (existingTune) {
|
||||||
|
// this is someone elses tune
|
||||||
|
if (existingTune.userId !== currentUser?.$id) {
|
||||||
|
navigateToNewTuneId();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialValues(existingTune);
|
||||||
|
setIsEditMode(true);
|
||||||
|
setTuneDocumentId(existingTune.$id);
|
||||||
|
|
||||||
|
if (existingTune.tuneFileId) {
|
||||||
|
const file = await getFile(existingTune.tuneFileId, await getBucketId(currentUser!.$id));
|
||||||
|
setTuneFileId(existingTune.tuneFileId);
|
||||||
|
setDefaultTuneFileList([{
|
||||||
|
uid: file.$id,
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTune.customIniFileId) {
|
||||||
|
const file = await getFile(existingTune.customIniFileId, await getBucketId(currentUser!.$id));
|
||||||
|
setCustomIniFileId(existingTune.customIniFileId);
|
||||||
|
setDefaultCustomIniFileList([{
|
||||||
|
uid: file.$id,
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingTune.logFileIds?.forEach(async (fileId: string) => {
|
||||||
|
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
|
||||||
|
setLogFileIds((prev) => new Map(prev).set(fileId, fileId));
|
||||||
|
setDefaultLogFilesList((prev) => [...prev, {
|
||||||
|
uid: file.$id,
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
existingTune.toothLogFileIds?.forEach(async (fileId: string) => {
|
||||||
|
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
|
||||||
|
setToothLogFileIds((prev) => new Map(prev).set(fileId, fileId));
|
||||||
|
setDefaultToothLogFilesList((prev) => [...prev, {
|
||||||
|
uid: file.$id,
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTuneIsLoading(false);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const prepareData = useCallback(async () => {
|
const prepareData = useCallback(async () => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
restrictedPage();
|
restrictedPage();
|
||||||
|
@ -410,8 +460,7 @@ const UploadPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshToken();
|
if (!currentUser.emailVerification) {
|
||||||
if (!currentUser.emailVerified) {
|
|
||||||
emailNotVerified();
|
emailNotVerified();
|
||||||
navigate(Routes.LOGIN);
|
navigate(Routes.LOGIN);
|
||||||
|
|
||||||
|
@ -424,14 +473,18 @@ const UploadPage = () => {
|
||||||
genericError(error as Error);
|
genericError(error as Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tuneId = nanoidCustom();
|
const currentTuneId = routeMatch?.params.tuneId;
|
||||||
setNewTuneId(tuneId);
|
if (currentTuneId) {
|
||||||
console.log('New tuneId:', tuneId);
|
loadExistingTune(currentTuneId);
|
||||||
}, [currentUser, navigate, refreshToken]);
|
setShareUrl(buildFullUrl([tunePath(currentTuneId)]));
|
||||||
|
} else {
|
||||||
|
navigateToNewTuneId();
|
||||||
|
}
|
||||||
|
}, [currentUser, loadExistingTune, navigate, navigateToNewTuneId, routeMatch?.params.tuneId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
prepareData();
|
prepareData();
|
||||||
}, [currentUser, prepareData, refreshToken]);
|
}, [currentUser, prepareData]);
|
||||||
|
|
||||||
const uploadButton = (
|
const uploadButton = (
|
||||||
<Space direction="vertical">
|
<Space direction="vertical">
|
||||||
|
@ -467,7 +520,7 @@ const UploadPage = () => {
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
>
|
>
|
||||||
Publish
|
{isEditMode ? 'Update' : 'Publish'}
|
||||||
</Button> : <Button
|
</Button> : <Button
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
|
@ -486,78 +539,85 @@ const UploadPage = () => {
|
||||||
<Space>Details</Space>
|
<Space>Details</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col span={24} sm={24}>
|
||||||
<Item name="make" rules={requiredRules}>
|
<Item name="vehicleName" rules={requiredTextRules}>
|
||||||
<Input addonBefore="Make"/>
|
<Input addonBefore="Vehicle name" />
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item name="model" rules={requiredRules}>
|
|
||||||
<Input addonBefore="Model"/>
|
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="year" rules={requiredRules}>
|
<Item name="engineMake" rules={requiredTextRules}>
|
||||||
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
|
<Input addonBefore="Engine make" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item name="engineCode" rules={requiredTextRules}>
|
||||||
|
<Input addonBefore="Engine code" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="displacement" rules={requiredRules}>
|
<Item name="displacement" rules={requiredRules}>
|
||||||
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
|
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
|
||||||
<Row {...rowProps}>
|
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="hp">
|
<Item name="cylindersCount" rules={requiredRules}>
|
||||||
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} />
|
<InputNumber addonBefore="Cylinders" style={{ width: '100%' }} min={0} max={16} />
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item name="stockHp">
|
|
||||||
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} />
|
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="engineCode">
|
<Item name="aspiration" rules={requiredTextRules}>
|
||||||
<Input addonBefore="Engine code"/>
|
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item name="cylindersCount">
|
|
||||||
<InputNumber addonBefore="No of cylinders" style={{ width: '100%' }} min={0} />
|
|
||||||
</Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row {...rowProps}>
|
|
||||||
<Col {...colProps}>
|
|
||||||
<Item name="aspiration">
|
|
||||||
<Select placeholder="Aspiration" style={{ width: '100%' }}>
|
<Select placeholder="Aspiration" style={{ width: '100%' }}>
|
||||||
<Select.Option value="na">Naturally aspirated</Select.Option>
|
<Select.Option value="na">Naturally aspirated</Select.Option>
|
||||||
<Select.Option value="turbocharger">Turbocharged</Select.Option>
|
<Select.Option value="turbocharged">Turbocharged</Select.Option>
|
||||||
<Select.Option value="supercharger">Supercharged</Select.Option>
|
<Select.Option value="supercharged">Supercharged</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item name="compression">
|
||||||
|
<InputNumber addonBefore="Compression" style={{ width: '100%' }} min={0} max={100} step={0.1} addonAfter=":1" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="fuel">
|
<Item name="fuel">
|
||||||
<Input addonBefore="Fuel" />
|
<Input addonBefore="Fuel" />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item name="ignition">
|
||||||
|
<Input addonBefore="Ignition" />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row {...rowProps}>
|
<Row {...rowProps}>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="injectorsSize">
|
<Item name="injectorsSize">
|
||||||
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} />
|
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} max={100_000} />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col {...colProps}>
|
<Col {...colProps}>
|
||||||
<Item name="coils">
|
<Item name="year">
|
||||||
<Input addonBefore="Coils" />
|
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row {...rowProps}>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item name="hp">
|
||||||
|
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} max={100_000} />
|
||||||
|
</Item>
|
||||||
|
</Col>
|
||||||
|
<Col {...colProps}>
|
||||||
|
<Item name="stockHp">
|
||||||
|
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} max={100_000} />
|
||||||
</Item>
|
</Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -587,12 +647,6 @@ const UploadPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Divider>
|
|
||||||
Visibility
|
|
||||||
</Divider>
|
|
||||||
<Item name="isListed" label="Listed:" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Item>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -605,18 +659,19 @@ const UploadPage = () => {
|
||||||
</Space>
|
</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Upload
|
<Upload
|
||||||
|
key={defaultLogFilesList.map((file) => file.uid).join('-') || 'logs'}
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
customRequest={uploadLogs}
|
customRequest={uploadLogs}
|
||||||
data={logFileData}
|
|
||||||
onRemove={removeLogFile}
|
onRemove={removeLogFile}
|
||||||
iconRender={logIcon}
|
iconRender={logIcon}
|
||||||
multiple
|
multiple
|
||||||
maxCount={MaxFiles.LOG_FILES}
|
maxCount={MaxFiles.LOG_FILES}
|
||||||
disabled={isPublished}
|
disabled={isPublished}
|
||||||
onPreview={noop}
|
onPreview={noop}
|
||||||
|
defaultFileList={defaultLogFilesList}
|
||||||
accept=".mlg,.csv,.msl"
|
accept=".mlg,.csv,.msl"
|
||||||
>
|
>
|
||||||
{Object.keys(logFiles).length < MaxFiles.LOG_FILES && uploadButton}
|
{logFileIds.size < MaxFiles.LOG_FILES && uploadButton}
|
||||||
</Upload>
|
</Upload>
|
||||||
<Divider>
|
<Divider>
|
||||||
<Space>
|
<Space>
|
||||||
|
@ -625,17 +680,18 @@ const UploadPage = () => {
|
||||||
</Space>
|
</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Upload
|
<Upload
|
||||||
|
key={defaultToothLogFilesList.map((file) => file.uid).join('-') || 'toothLogs'}
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
customRequest={uploadToothLogs}
|
customRequest={uploadToothLogs}
|
||||||
data={toothLogFilesData}
|
|
||||||
onRemove={removeToothLogFile}
|
onRemove={removeToothLogFile}
|
||||||
iconRender={toothLogIcon}
|
iconRender={toothLogIcon}
|
||||||
multiple
|
multiple
|
||||||
maxCount={MaxFiles.TOOTH_LOG_FILES}
|
maxCount={MaxFiles.TOOTH_LOG_FILES}
|
||||||
onPreview={noop}
|
onPreview={noop}
|
||||||
|
defaultFileList={defaultToothLogFilesList}
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
>
|
>
|
||||||
{Object.keys(toothLogFiles).length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
|
{toothLogFileIds.size < MaxFiles.TOOTH_LOG_FILES && uploadButton}
|
||||||
</Upload>
|
</Upload>
|
||||||
<Divider>
|
<Divider>
|
||||||
<Space>
|
<Space>
|
||||||
|
@ -644,23 +700,24 @@ const UploadPage = () => {
|
||||||
</Space>
|
</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Upload
|
<Upload
|
||||||
|
key={defaultCustomIniFileList[0]?.uid || 'customIni'}
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
customRequest={uploadCustomIni}
|
customRequest={uploadCustomIni}
|
||||||
data={customIniFileData}
|
|
||||||
onRemove={removeCustomIniFile}
|
onRemove={removeCustomIniFile}
|
||||||
iconRender={iniIcon}
|
iconRender={iniIcon}
|
||||||
disabled={isPublished}
|
disabled={isPublished}
|
||||||
onPreview={noop}
|
onPreview={noop}
|
||||||
|
defaultFileList={defaultCustomIniFileList}
|
||||||
accept=".ini"
|
accept=".ini"
|
||||||
>
|
>
|
||||||
{!customIniFile && uploadButton}
|
{!customIniFileId && uploadButton}
|
||||||
</Upload>
|
</Upload>
|
||||||
{detailsSection}
|
{detailsSection}
|
||||||
{shareUrl && tuneFile && shareSection}
|
{shareUrl && tuneFileId && shareSection}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isUserAuthorized) {
|
if (!isUserAuthorized || isTuneLoading) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -674,13 +731,7 @@ const UploadPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="small-container">
|
<div className="small-container">
|
||||||
<Form
|
<Form onFinish={publishTune} initialValues={initialValues}>
|
||||||
onFinish={publish}
|
|
||||||
initialValues={{
|
|
||||||
readme: '# My Tune\n\ndescription',
|
|
||||||
isListed: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Divider>
|
<Divider>
|
||||||
<Space>
|
<Space>
|
||||||
Upload Tune
|
Upload Tune
|
||||||
|
@ -688,18 +739,19 @@ const UploadPage = () => {
|
||||||
</Space>
|
</Space>
|
||||||
</Divider>
|
</Divider>
|
||||||
<Upload
|
<Upload
|
||||||
|
key={defaultTuneFileList[0]?.uid || 'tuneFile'}
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
customRequest={uploadTune}
|
customRequest={uploadTune}
|
||||||
data={tuneFileData}
|
|
||||||
onRemove={removeTuneFile}
|
onRemove={removeTuneFile}
|
||||||
iconRender={tuneIcon}
|
iconRender={tuneIcon}
|
||||||
disabled={isPublished}
|
disabled={isPublished}
|
||||||
onPreview={noop}
|
onPreview={noop}
|
||||||
|
defaultFileList={defaultTuneFileList}
|
||||||
accept=".msq"
|
accept=".msq"
|
||||||
>
|
>
|
||||||
{tuneFile === null && uploadButton}
|
{tuneFileId === null && uploadButton}
|
||||||
</Upload>
|
</Upload>
|
||||||
{tuneFile && optionalSection}
|
{(tuneFileId || defaultTuneFileList.length > 0) && optionalSection}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 {
|
import {
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
UnlockOutlined,
|
||||||
GoogleOutlined,
|
GoogleOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
|
FacebookOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
|
@ -26,90 +28,166 @@ import {
|
||||||
emailNotVerified,
|
emailNotVerified,
|
||||||
logInFailed,
|
logInFailed,
|
||||||
logInSuccessful,
|
logInSuccessful,
|
||||||
|
magicLinkSent,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
import {
|
||||||
|
emailRules,
|
||||||
|
requiredRules,
|
||||||
|
} from '../../utils/form';
|
||||||
|
|
||||||
const { Item } = Form;
|
const { Item } = Form;
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [form] = Form.useForm();
|
const [formMagicLink] = Form.useForm();
|
||||||
|
const [formEmail] = Form.useForm();
|
||||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||||
const { login, googleAuth, githubAuth } = useAuth();
|
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
|
||||||
|
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
|
||||||
|
const { login, googleAuth, githubAuth, facebookAuth, sendMagicLink } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading;
|
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
|
||||||
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
|
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
|
||||||
|
|
||||||
const googleLogin = useCallback(async () => {
|
const googleLogin = useCallback(async () => {
|
||||||
setIsGoogleLoading(true);
|
setIsGoogleLoading(true);
|
||||||
try {
|
try {
|
||||||
await googleAuth();
|
await googleAuth();
|
||||||
logInSuccessful();
|
|
||||||
redirectAfterLogin();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logInFailed(error as Error);
|
logInFailed(error as Error);
|
||||||
setIsGoogleLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [googleAuth, redirectAfterLogin]);
|
}, [googleAuth]);
|
||||||
|
|
||||||
const githubLogin = useCallback(async () => {
|
const githubLogin = useCallback(async () => {
|
||||||
setIsGithubLoading(true);
|
setIsGithubLoading(true);
|
||||||
try {
|
try {
|
||||||
await githubAuth();
|
await githubAuth();
|
||||||
logInSuccessful();
|
|
||||||
redirectAfterLogin();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logInFailed(error as Error);
|
logInFailed(error as Error);
|
||||||
setIsGithubLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [githubAuth, redirectAfterLogin]);
|
}, [githubAuth]);
|
||||||
|
|
||||||
const emailLogin = async ({ email, password }: { form: any, email: string, password: string }) => {
|
const facebookLogin = async () => {
|
||||||
|
setIsFacebookLoading(true);
|
||||||
|
try {
|
||||||
|
await facebookAuth();
|
||||||
|
} catch (error) {
|
||||||
|
logInFailed(error as Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailLogin = async ({ email, password }: { email: string, password: string }) => {
|
||||||
setIsEmailLoading(true);
|
setIsEmailLoading(true);
|
||||||
try {
|
try {
|
||||||
const userCredentials = await login(email, password);
|
const user = await login(email, password);
|
||||||
logInSuccessful();
|
logInSuccessful();
|
||||||
|
if (!user.emailVerification) {
|
||||||
if (!userCredentials.user.emailVerified) {
|
|
||||||
emailNotVerified();
|
emailNotVerified();
|
||||||
}
|
}
|
||||||
|
if (!user.name) {
|
||||||
|
navigate(Routes.PROFILE);
|
||||||
|
}
|
||||||
redirectAfterLogin();
|
redirectAfterLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
form.resetFields();
|
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
logInFailed(error as Error);
|
logInFailed(error as Error);
|
||||||
|
formMagicLink.resetFields();
|
||||||
|
formEmail.resetFields();
|
||||||
setIsEmailLoading(false);
|
setIsEmailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const magicLinkLogin = async ({ email }: { email: string }) => {
|
||||||
|
setIsMagicLinkLoading(true);
|
||||||
|
try {
|
||||||
|
await sendMagicLink(email);
|
||||||
|
magicLinkSent();
|
||||||
|
} catch (error) {
|
||||||
|
logInFailed(error as Error);
|
||||||
|
} finally {
|
||||||
|
setIsMagicLinkLoading(false);
|
||||||
|
formMagicLink.resetFields();
|
||||||
|
formEmail.resetFields();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="small-container">
|
<div className="auth-container">
|
||||||
<Divider>Log In using email</Divider>
|
<Divider>Log In</Divider>
|
||||||
<Form
|
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
onFinish={emailLogin}
|
<Button
|
||||||
validateMessages={validateMessages}
|
loading={isGoogleLoading}
|
||||||
autoComplete="off"
|
onClick={googleLogin}
|
||||||
form={form}
|
disabled={isAnythingLoading}
|
||||||
>
|
|
||||||
<Item
|
|
||||||
name="email"
|
|
||||||
rules={[{ required: true, type: 'email' }]}
|
|
||||||
hasFeedback
|
|
||||||
>
|
>
|
||||||
|
<GoogleOutlined />Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isGithubLoading}
|
||||||
|
onClick={githubLogin}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
>
|
||||||
|
<GithubOutlined />GitHub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isFacebookLoading}
|
||||||
|
onClick={facebookLogin}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
>
|
||||||
|
<FacebookOutlined />Facebook
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Divider />
|
||||||
|
<Form
|
||||||
|
onFinish={magicLinkLogin}
|
||||||
|
validateMessages={validateMessages}
|
||||||
|
form={formMagicLink}
|
||||||
|
>
|
||||||
|
<Item name="email" rules={emailRules} hasFeedback>
|
||||||
<Input
|
<Input
|
||||||
prefix={<MailOutlined />}
|
prefix={<MailOutlined />}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
|
id="email-magic-link"
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
loading={isMagicLinkLoading}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
icon={<MailOutlined />}
|
||||||
|
>
|
||||||
|
Send me a Magic Link
|
||||||
|
</Button>
|
||||||
|
</Item>
|
||||||
|
</Form>
|
||||||
|
<Form
|
||||||
|
onFinish={emailLogin}
|
||||||
|
validateMessages={validateMessages}
|
||||||
|
form={formEmail}
|
||||||
|
>
|
||||||
|
<Divider />
|
||||||
|
<Item name="email" rules={emailRules} hasFeedback>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined />}
|
||||||
|
placeholder="Email"
|
||||||
|
autoComplete="email"
|
||||||
disabled={isAnythingLoading}
|
disabled={isAnythingLoading}
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
<Item
|
<Item
|
||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true }]}
|
rules={requiredRules}
|
||||||
hasFeedback
|
hasFeedback
|
||||||
>
|
>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
|
autoComplete="current-password"
|
||||||
prefix={<LockOutlined />}
|
prefix={<LockOutlined />}
|
||||||
disabled={isAnythingLoading}
|
disabled={isAnythingLoading}
|
||||||
/>
|
/>
|
||||||
|
@ -121,39 +199,18 @@ const Login = () => {
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
loading={isEmailLoading}
|
loading={isEmailLoading}
|
||||||
disabled={isAnythingLoading}
|
disabled={isAnythingLoading}
|
||||||
|
icon={<UnlockOutlined />}
|
||||||
>
|
>
|
||||||
Log In
|
Log in using password
|
||||||
</Button>
|
</Button>
|
||||||
</Item>
|
</Item>
|
||||||
</Form>
|
<Link to={Routes.SIGN_UP}>
|
||||||
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
|
Sign Up
|
||||||
<Item>
|
</Link>
|
||||||
<Button
|
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
|
||||||
loading={isGoogleLoading}
|
|
||||||
onClick={googleLogin}
|
|
||||||
disabled={isAnythingLoading}
|
|
||||||
>
|
|
||||||
<GoogleOutlined />Google
|
|
||||||
</Button>
|
|
||||||
</Item>
|
|
||||||
<Item>
|
|
||||||
<Button
|
|
||||||
loading={isGithubLoading}
|
|
||||||
onClick={githubLogin}
|
|
||||||
disabled={isAnythingLoading}
|
|
||||||
>
|
|
||||||
<GithubOutlined />GitHub
|
|
||||||
</Button>
|
|
||||||
</Item>
|
|
||||||
</Space>
|
|
||||||
<Button type="link">
|
|
||||||
<Link to={Routes.SIGN_UP}>Sign Up</Link>
|
|
||||||
</Button>
|
|
||||||
<Button type="link" style={{ float: 'right' }}>
|
|
||||||
<Link to={Routes.RESET_PASSWORD}>
|
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
|
Alert,
|
||||||
|
Space,
|
||||||
|
List,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { UserOutlined } from '@ant-design/icons';
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import validateMessages from './validateMessages';
|
import validateMessages from './validateMessages';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { restrictedPage } from './notifications';
|
import {
|
||||||
|
restrictedPage,
|
||||||
|
sendingEmailVerificationFailed,
|
||||||
|
emailVerificationSent,
|
||||||
|
profileUpdateSuccess,
|
||||||
|
profileUpdateFailed,
|
||||||
|
passwordUpdateSuccess,
|
||||||
|
passwordUpdateFailed,
|
||||||
|
} from './notifications';
|
||||||
import { Routes } from '../../routes';
|
import { Routes } from '../../routes';
|
||||||
|
import {
|
||||||
|
passwordRules,
|
||||||
|
requiredRules,
|
||||||
|
} from '../../utils/form';
|
||||||
|
|
||||||
const { Item } = Form;
|
const { Item } = Form;
|
||||||
|
|
||||||
|
const MAX_LIST_SIZE = 10;
|
||||||
|
|
||||||
|
const parseLogEvent = (raw: string) => {
|
||||||
|
const split = raw.split('.');
|
||||||
|
return [split[0], split[2], split[4]].join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const { currentUser } = useAuth();
|
const [formProfile] = Form.useForm();
|
||||||
|
const [formPassword] = Form.useForm();
|
||||||
|
const {
|
||||||
|
currentUser,
|
||||||
|
sendEmailVerification,
|
||||||
|
updateUsername,
|
||||||
|
updatePassword,
|
||||||
|
getSessions,
|
||||||
|
getLogs,
|
||||||
|
} = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [isVerificationSent, setIsVerificationSent] = useState(false);
|
||||||
|
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
||||||
|
const [isProfileLoading, setIsProfileLoading] = useState(false);
|
||||||
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
|
const [sessions, setSessions] = useState<string[]>([]);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const resendEmailVerification = async () => {
|
||||||
|
setIsSendingVerification(true);
|
||||||
|
setIsVerificationSent(true);
|
||||||
|
try {
|
||||||
|
await sendEmailVerification();
|
||||||
|
emailVerificationSent();
|
||||||
|
} catch (error) {
|
||||||
|
sendingEmailVerificationFailed(error as Error);
|
||||||
|
setIsVerificationSent(false);
|
||||||
|
} finally {
|
||||||
|
setIsSendingVerification(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => getLogs()
|
||||||
|
.then((list) => setLogs(list.logs.slice(0, MAX_LIST_SIZE).map((log) => [
|
||||||
|
new Date(log.time * 1000).toLocaleString(),
|
||||||
|
parseLogEvent(log.event),
|
||||||
|
log.clientName,
|
||||||
|
log.clientEngineVersion,
|
||||||
|
log.osName,
|
||||||
|
log.deviceName,
|
||||||
|
log.countryName,
|
||||||
|
log.ip,
|
||||||
|
].join(' | ')))), [getLogs]);
|
||||||
|
|
||||||
|
const onUpdateProfile = async ({ username }: { username: string }) => {
|
||||||
|
setIsProfileLoading(true);
|
||||||
|
try {
|
||||||
|
await updateUsername(username);
|
||||||
|
profileUpdateSuccess();
|
||||||
|
fetchLogs();
|
||||||
|
} catch (error) {
|
||||||
|
profileUpdateFailed(error as Error);
|
||||||
|
} finally {
|
||||||
|
setIsProfileLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdatePassword = async ({ password, oldPassword }: { password: string, oldPassword: string }) => {
|
||||||
|
setIsPasswordLoading(true);
|
||||||
|
try {
|
||||||
|
await updatePassword(password, oldPassword);
|
||||||
|
passwordUpdateSuccess();
|
||||||
|
fetchLogs();
|
||||||
|
formPassword.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
passwordUpdateFailed(error as Error);
|
||||||
|
} finally {
|
||||||
|
setIsPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) {
|
if (currentUser) {
|
||||||
restrictedPage();
|
getSessions()
|
||||||
navigate(Routes.LOGIN);
|
.then((list) => setSessions(list.sessions.slice(0, MAX_LIST_SIZE).map((ses) => [
|
||||||
|
ses.clientName,
|
||||||
|
ses.osName,
|
||||||
|
ses.deviceName,
|
||||||
|
ses.countryName,
|
||||||
|
ses.ip,
|
||||||
|
].join(' | '))));
|
||||||
|
|
||||||
|
fetchLogs();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [currentUser, navigate]);
|
|
||||||
|
restrictedPage();
|
||||||
|
navigate(Routes.LOGIN);
|
||||||
|
}, [currentUser, fetchLogs, getLogs, getSessions, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="small-container">
|
<>
|
||||||
<Divider>Your Profile</Divider>
|
<div className="auth-container">
|
||||||
<Form
|
{!currentUser?.emailVerification && (<>
|
||||||
validateMessages={validateMessages}
|
<Divider>Email verification</Divider>
|
||||||
form={form}
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
autoComplete="off"
|
<Alert message="Your email address is not verified!" type="error" showIcon />
|
||||||
>
|
<Button
|
||||||
<Item
|
type="primary"
|
||||||
name="username"
|
style={{ width: '100%' }}
|
||||||
rules={[{ required: true }]}
|
icon={<MailOutlined />}
|
||||||
hasFeedback
|
disabled={isVerificationSent}
|
||||||
|
loading={isSendingVerification}
|
||||||
|
onClick={resendEmailVerification}
|
||||||
|
>
|
||||||
|
Resend verification
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</>)}
|
||||||
|
<Divider>Your Profile</Divider>
|
||||||
|
<Form
|
||||||
|
validateMessages={validateMessages}
|
||||||
|
form={formProfile}
|
||||||
|
onFinish={onUpdateProfile}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
name: 'username',
|
||||||
|
value: currentUser?.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
value: currentUser?.email,
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input prefix={<UserOutlined />} placeholder="Username" />
|
<Item
|
||||||
</Item>
|
name="username"
|
||||||
<Item>
|
rules={requiredRules}
|
||||||
<Button
|
hasFeedback
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
>
|
||||||
Save
|
<Input
|
||||||
</Button>
|
prefix={<UserOutlined />}
|
||||||
</Item>
|
placeholder="Username"
|
||||||
</Form>
|
autoComplete="name"
|
||||||
</div>
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item name="email">
|
||||||
|
<Input prefix={<MailOutlined />} placeholder="Email" disabled />
|
||||||
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
loading={isProfileLoading}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Item>
|
||||||
|
</Form>
|
||||||
|
<Divider>Password</Divider>
|
||||||
|
<Form
|
||||||
|
validateMessages={validateMessages}
|
||||||
|
form={formPassword}
|
||||||
|
onFinish={onUpdatePassword}
|
||||||
|
>
|
||||||
|
<Item
|
||||||
|
name="oldPassword"
|
||||||
|
rules={requiredRules}
|
||||||
|
hasFeedback
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Old password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
name="password"
|
||||||
|
rules={passwordRules}
|
||||||
|
hasFeedback
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="New password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
icon={<LockOutlined />}
|
||||||
|
loading={isPasswordLoading}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<div className="large-container">
|
||||||
|
<Divider>Active sessions</Divider>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
dataSource={sessions}
|
||||||
|
renderItem={item => <List.Item>{item}</List.Item>}
|
||||||
|
loading={sessions.length === 0}
|
||||||
|
/>
|
||||||
|
<Divider>Audit logs</Divider>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
dataSource={logs}
|
||||||
|
renderItem={item => <List.Item>{item}</List.Item>}
|
||||||
|
loading={logs.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -17,19 +17,20 @@ import {
|
||||||
resetFailed,
|
resetFailed,
|
||||||
resetSuccessful,
|
resetSuccessful,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
import { emailRules } from '../../utils/form';
|
||||||
|
|
||||||
const { Item } = Form;
|
const { Item } = Form;
|
||||||
|
|
||||||
const ResetPassword = () => {
|
const ResetPassword = () => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { resetPassword } = useAuth();
|
const { initResetPassword } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const onFinish = async ({ email }: { form: any, email: string }) => {
|
const onFinish = async ({ email }: { form: any, email: string }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await resetPassword(email);
|
await initResetPassword(email);
|
||||||
resetSuccessful();
|
resetSuccessful();
|
||||||
navigate(Routes.LOGIN);
|
navigate(Routes.LOGIN);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -41,23 +42,23 @@ const ResetPassword = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="small-container">
|
<div className="auth-container">
|
||||||
<Divider>Reset password</Divider>
|
<Divider>Reset password</Divider>
|
||||||
<Form
|
<Form
|
||||||
initialValues={{ remember: true }}
|
initialValues={{ remember: true }}
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
validateMessages={validateMessages}
|
validateMessages={validateMessages}
|
||||||
autoComplete="off"
|
|
||||||
form={form}
|
form={form}
|
||||||
>
|
>
|
||||||
<Item
|
<Item
|
||||||
name="email"
|
name="email"
|
||||||
rules={[{ required: true, type: 'email' }]}
|
rules={emailRules}
|
||||||
hasFeedback
|
hasFeedback
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
prefix={<MailOutlined />}
|
prefix={<MailOutlined />}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
<Item>
|
<Item>
|
||||||
|
|
|
@ -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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
|
Space,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
GoogleOutlined,
|
||||||
|
GithubOutlined,
|
||||||
|
FacebookOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
|
@ -19,85 +29,142 @@ import validateMessages from './validateMessages';
|
||||||
import {
|
import {
|
||||||
emailNotVerified,
|
emailNotVerified,
|
||||||
signUpFailed,
|
signUpFailed,
|
||||||
|
magicLinkSent,
|
||||||
signUpSuccessful,
|
signUpSuccessful,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
|
import {
|
||||||
|
emailRules,
|
||||||
|
passwordRules,
|
||||||
|
requiredRules,
|
||||||
|
} from '../../utils/form';
|
||||||
|
|
||||||
const { Item } = Form;
|
const { Item } = Form;
|
||||||
|
|
||||||
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
|
|
||||||
|
|
||||||
const SignUp = () => {
|
const SignUp = () => {
|
||||||
const [form] = Form.useForm();
|
const [formMagicLink] = Form.useForm();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [formEmail] = Form.useForm();
|
||||||
const { signUp } = useAuth();
|
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||||
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
|
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||||
|
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
|
||||||
|
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
|
||||||
|
const {
|
||||||
|
currentUser,
|
||||||
|
signUp,
|
||||||
|
sendEmailVerification,
|
||||||
|
googleAuth,
|
||||||
|
githubAuth,
|
||||||
|
facebookAuth,
|
||||||
|
sendMagicLink,
|
||||||
|
} = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
|
||||||
|
|
||||||
const onFinish = async ({ email, password }: { email: string, password: string }) => {
|
const googleLogin = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsGoogleLoading(true);
|
||||||
try {
|
try {
|
||||||
await signUp(email, password);
|
await googleAuth();
|
||||||
|
} catch (error) {
|
||||||
|
signUpFailed(error as Error);
|
||||||
|
}
|
||||||
|
}, [googleAuth]);
|
||||||
|
|
||||||
|
const githubLogin = useCallback(async () => {
|
||||||
|
setIsGithubLoading(true);
|
||||||
|
try {
|
||||||
|
await githubAuth();
|
||||||
|
} catch (error) {
|
||||||
|
signUpFailed(error as Error);
|
||||||
|
}
|
||||||
|
}, [githubAuth]);
|
||||||
|
|
||||||
|
const facebookLogin = useCallback(async () => {
|
||||||
|
setIsFacebookLoading(true);
|
||||||
|
try {
|
||||||
|
await facebookAuth();
|
||||||
|
} catch (error) {
|
||||||
|
signUpFailed(error as Error);
|
||||||
|
}
|
||||||
|
}, [facebookAuth]);
|
||||||
|
|
||||||
|
const emailSignUp = async ({ email, password, username }: { email: string, password: string, username: string }) => {
|
||||||
|
setIsEmailLoading(true);
|
||||||
|
try {
|
||||||
|
const user = await signUp(email, password, username);
|
||||||
|
await sendEmailVerification();
|
||||||
signUpSuccessful();
|
signUpSuccessful();
|
||||||
emailNotVerified();
|
if (!user.emailVerification) {
|
||||||
|
emailNotVerified();
|
||||||
|
}
|
||||||
navigate(Routes.HUB);
|
navigate(Routes.HUB);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
form.resetFields();
|
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
signUpFailed(error as Error);
|
signUpFailed(error as Error);
|
||||||
setIsLoading(false);
|
formMagicLink.resetFields();
|
||||||
|
formEmail.resetFields();
|
||||||
|
setIsEmailLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const magicLinkLogin = async ({ email }: { email: string }) => {
|
||||||
|
setIsMagicLinkLoading(true);
|
||||||
|
try {
|
||||||
|
await sendMagicLink(email);
|
||||||
|
magicLinkSent();
|
||||||
|
} catch (error) {
|
||||||
|
signUpFailed(error as Error);
|
||||||
|
} finally {
|
||||||
|
setIsMagicLinkLoading(false);
|
||||||
|
formMagicLink.resetFields();
|
||||||
|
formEmail.resetFields();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser) {
|
||||||
|
navigate(Routes.HUB);
|
||||||
|
}
|
||||||
|
}, [currentUser, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="small-container">
|
<div className="auth-container">
|
||||||
<Divider>Sign Up</Divider>
|
<Divider>Sign Up</Divider>
|
||||||
<Form
|
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
onFinish={onFinish}
|
<Button
|
||||||
validateMessages={validateMessages}
|
loading={isGoogleLoading}
|
||||||
autoComplete="off"
|
onClick={googleLogin}
|
||||||
form={form}
|
disabled={isAnythingLoading}
|
||||||
>
|
|
||||||
<Item
|
|
||||||
name="email"
|
|
||||||
rules={[{ required: true, type: 'email' }]}
|
|
||||||
hasFeedback
|
|
||||||
>
|
>
|
||||||
|
<GoogleOutlined />Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isGithubLoading}
|
||||||
|
onClick={githubLogin}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
>
|
||||||
|
<GithubOutlined />GitHub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
loading={isFacebookLoading}
|
||||||
|
onClick={facebookLogin}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
>
|
||||||
|
<FacebookOutlined />Facebook
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Divider />
|
||||||
|
<Form
|
||||||
|
onFinish={magicLinkLogin}
|
||||||
|
validateMessages={validateMessages}
|
||||||
|
form={formMagicLink}
|
||||||
|
>
|
||||||
|
<Item name="email" rules={emailRules} hasFeedback>
|
||||||
<Input
|
<Input
|
||||||
prefix={<MailOutlined />}
|
prefix={<MailOutlined />}
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
/>
|
id="email-magic-link"
|
||||||
</Item>
|
autoComplete="email"
|
||||||
<Item
|
disabled={isAnythingLoading}
|
||||||
name="password"
|
|
||||||
rules={[
|
|
||||||
{ required: true },
|
|
||||||
{ pattern: passwordPattern, message: 'Password is too weak!' },
|
|
||||||
]}
|
|
||||||
hasFeedback
|
|
||||||
>
|
|
||||||
<Input.Password
|
|
||||||
placeholder="Password"
|
|
||||||
prefix={<LockOutlined />}
|
|
||||||
/>
|
|
||||||
</Item>
|
|
||||||
<Item
|
|
||||||
name="passwordConfirmation"
|
|
||||||
rules={[
|
|
||||||
{ required: true },
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (!value || getFieldValue('password') === value) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error('Passwords don\'t match!'));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
hasFeedback
|
|
||||||
>
|
|
||||||
<Input.Password
|
|
||||||
placeholder="Password confirmation"
|
|
||||||
prefix={<LockOutlined />}
|
|
||||||
/>
|
/>
|
||||||
</Item>
|
</Item>
|
||||||
<Item>
|
<Item>
|
||||||
|
@ -105,15 +172,64 @@ const SignUp = () => {
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
loading={isLoading}
|
loading={isMagicLinkLoading}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
icon={<MailOutlined />}
|
||||||
>
|
>
|
||||||
Sign Up
|
Send me a Magic Link
|
||||||
</Button>
|
</Button>
|
||||||
</Item>
|
</Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
|
<Form
|
||||||
Log In
|
onFinish={emailSignUp}
|
||||||
</Link>
|
validateMessages={validateMessages}
|
||||||
|
form={formEmail}
|
||||||
|
>
|
||||||
|
<Divider />
|
||||||
|
<Item name="username" rules={requiredRules} hasFeedback>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="Username"
|
||||||
|
autoComplete="name"
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item name="email" rules={emailRules} hasFeedback>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined />}
|
||||||
|
placeholder="Email"
|
||||||
|
autoComplete="email"
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
name="password"
|
||||||
|
rules={passwordRules}
|
||||||
|
hasFeedback
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
/>
|
||||||
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
loading={isEmailLoading}
|
||||||
|
disabled={isAnythingLoading}
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
>
|
||||||
|
Sign Up using password
|
||||||
|
</Button>
|
||||||
|
</Item>
|
||||||
|
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
|
||||||
|
Log In
|
||||||
|
</Link>
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,6 +13,12 @@ const emailNotVerified = () => notification.warning({
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const magicLinkSent = () => notification.success({
|
||||||
|
message: 'Check your email',
|
||||||
|
description: 'Magic link sent!',
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const signUpSuccessful = () => notification.success({
|
const signUpSuccessful = () => notification.success({
|
||||||
message: 'Sign Up successful',
|
message: 'Sign Up successful',
|
||||||
description: 'Welcome on board!',
|
description: 'Welcome on board!',
|
||||||
|
@ -22,6 +28,7 @@ const signUpSuccessful = () => notification.success({
|
||||||
const signUpFailed = (err: Error) => notification.error({
|
const signUpFailed = (err: Error) => notification.error({
|
||||||
message: 'Failed to create an account',
|
message: 'Failed to create an account',
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const logInSuccessful = () => notification.success({
|
const logInSuccessful = () => notification.success({
|
||||||
|
@ -33,6 +40,7 @@ const logInSuccessful = () => notification.success({
|
||||||
const logInFailed = (err: Error) => notification.error({
|
const logInFailed = (err: Error) => notification.error({
|
||||||
message: 'Failed to log in',
|
message: 'Failed to log in',
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const restrictedPage = () => notification.error({
|
const restrictedPage = () => notification.error({
|
||||||
|
@ -50,10 +58,11 @@ const logOutSuccessful = () => notification.success({
|
||||||
const logOutFailed = (err: Error) => notification.error({
|
const logOutFailed = (err: Error) => notification.error({
|
||||||
message: 'Log out failed',
|
message: 'Log out failed',
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetSuccessful = () => notification.success({
|
const resetSuccessful = () => notification.success({
|
||||||
message: 'Password reset successful',
|
message: 'Password reset initiated',
|
||||||
description: 'Check your email!',
|
description: 'Check your email!',
|
||||||
...baseOptions,
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
@ -61,10 +70,72 @@ const resetSuccessful = () => notification.success({
|
||||||
const resetFailed = (err: Error) => notification.error({
|
const resetFailed = (err: Error) => notification.error({
|
||||||
message: 'Password reset failed',
|
message: 'Password reset failed',
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const magicLinkConfirmationFailed = (err: Error) => notification.error({
|
||||||
|
message: 'Magic Link is invalid',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendingEmailVerificationFailed = (err: Error) => notification.success({
|
||||||
|
message: 'Sending verification email failed',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailVerificationSent = () => notification.success({
|
||||||
|
message: 'Check your email',
|
||||||
|
description: 'Email verification sent!',
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailVerificationFailed = (err: Error) => notification.error({
|
||||||
|
message: 'Email verification failed',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailVerificationSuccess = () => notification.success({
|
||||||
|
message: 'Email verified',
|
||||||
|
description: 'Your email has been verified!',
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileUpdateSuccess = () => notification.success({
|
||||||
|
message: 'Profile updated',
|
||||||
|
description: 'Your profile has been updated!',
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileUpdateFailed = (err: Error) => notification.error({
|
||||||
|
message: 'Unable to update your profile',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordUpdateSuccess = () => notification.success({
|
||||||
|
message: 'Password changed',
|
||||||
|
description: 'Your password has been changed!',
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordUpdateFailed = (err: Error) => notification.error({
|
||||||
|
message: 'Unable to change your password',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseGenericError = (err: Error) => notification.error({
|
||||||
|
message: 'Database Error',
|
||||||
|
description: err.message,
|
||||||
|
...baseOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
export {
|
export {
|
||||||
emailNotVerified,
|
emailNotVerified,
|
||||||
|
magicLinkSent,
|
||||||
signUpSuccessful,
|
signUpSuccessful,
|
||||||
signUpFailed,
|
signUpFailed,
|
||||||
logInSuccessful,
|
logInSuccessful,
|
||||||
|
@ -74,4 +145,14 @@ export {
|
||||||
logOutFailed,
|
logOutFailed,
|
||||||
resetSuccessful,
|
resetSuccessful,
|
||||||
resetFailed,
|
resetFailed,
|
||||||
|
magicLinkConfirmationFailed,
|
||||||
|
sendingEmailVerificationFailed,
|
||||||
|
emailVerificationSent,
|
||||||
|
emailVerificationFailed,
|
||||||
|
emailVerificationSuccess,
|
||||||
|
profileUpdateSuccess,
|
||||||
|
profileUpdateFailed,
|
||||||
|
passwordUpdateSuccess,
|
||||||
|
passwordUpdateFailed,
|
||||||
|
databaseGenericError,
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,11 +10,20 @@ export enum Routes {
|
||||||
TUNE_LOGS = '/t/:tuneId/logs',
|
TUNE_LOGS = '/t/:tuneId/logs',
|
||||||
TUNE_DIAGNOSE = '/t/:tuneId/diagnose',
|
TUNE_DIAGNOSE = '/t/:tuneId/diagnose',
|
||||||
|
|
||||||
|
UPLOAD = '/upload',
|
||||||
|
UPLOAD_WITH_TUNE_ID = '/upload/:tuneId',
|
||||||
|
|
||||||
LOGIN = '/auth/login',
|
LOGIN = '/auth/login',
|
||||||
LOGOUT = '/auth/logout',
|
LOGOUT = '/auth/logout',
|
||||||
PROFILE = '/auth/profile',
|
PROFILE = '/auth/profile',
|
||||||
SIGN_UP = '/auth/sign-up',
|
SIGN_UP = '/auth/sign-up',
|
||||||
FORGOT_PASSWORD = '/auth/forgot-password',
|
FORGOT_PASSWORD = '/auth/forgot-password',
|
||||||
RESET_PASSWORD = '/auth/reset-password',
|
RESET_PASSWORD = '/auth/reset-password',
|
||||||
UPLOAD = '/upload',
|
MAGIC_LINK_CONFIRMATION = '/auth/magic-link-confirmation',
|
||||||
|
EMAIL_VERIFICATION = '/auth/email-verification',
|
||||||
|
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation',
|
||||||
|
|
||||||
|
REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION = 'magic-link-confirmation',
|
||||||
|
REDIRECT_PAGE_EMAIL_VERIFICATION = 'email-verification',
|
||||||
|
REDIRECT_PAGE_RESET_PASSWORD = 'reset-password',
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ const initialState: AppState = {
|
||||||
constants: {},
|
constants: {},
|
||||||
details: {} as any,
|
details: {} as any,
|
||||||
},
|
},
|
||||||
tuneData: {},
|
tuneData: {} as any,
|
||||||
logs: [],
|
logs: [],
|
||||||
config: {} as any,
|
config: {} as any,
|
||||||
ui: {
|
ui: {
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
import { Timestamp } from 'firebase/firestore/lite';
|
import { Models } from 'appwrite';
|
||||||
|
|
||||||
|
type Partial<T> = {
|
||||||
|
[A in keyof T]?: T[A];
|
||||||
|
};
|
||||||
|
|
||||||
export interface TuneDataDetails {
|
export interface TuneDataDetails {
|
||||||
readme?: string | null;
|
readme?: string | null;
|
||||||
|
@ -17,15 +21,37 @@ export interface TuneDataDetails {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TuneDbData {
|
export interface TuneDbData {
|
||||||
id?: string,
|
userId: string;
|
||||||
userUid?: string;
|
tuneId: string;
|
||||||
createdAt?: Date | Timestamp | string;
|
signature: string;
|
||||||
updatedAt?: Date | Timestamp | string;
|
tuneFileId?: string | null;
|
||||||
isPublished?: boolean;
|
logFileIds?: string[];
|
||||||
isListed?: boolean;
|
toothLogFileIds?: string[];
|
||||||
tuneFile?: string | null;
|
customIniFileId?: string | null;
|
||||||
logFiles?: string[];
|
vehicleName: string | null;
|
||||||
toothLogFiles?: string[];
|
engineMake: string | null;
|
||||||
customIniFile?: string | null;
|
engineCode: string | null;
|
||||||
details?: TuneDataDetails;
|
displacement: number | null;
|
||||||
|
cylindersCount: number | null;
|
||||||
|
aspiration: 'na' | 'turbocharged' | 'supercharged';
|
||||||
|
compression?: number | null;
|
||||||
|
fuel?: string | null;
|
||||||
|
ignition?: string | null;
|
||||||
|
injectorsSize?: number | null;
|
||||||
|
year?: number | null;
|
||||||
|
hp?: number | null;
|
||||||
|
stockHp?: number | null;
|
||||||
|
readme: string | null;
|
||||||
|
textSearch?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TuneDbDocument extends TuneDbData, Models.Document {}
|
||||||
|
|
||||||
|
export type TuneDbDataPartial = Partial<TuneDbData>;
|
||||||
|
|
||||||
|
export interface UsersBucket {
|
||||||
|
userId: string;
|
||||||
|
bucketId: string;
|
||||||
|
visibility: 'pubic' | 'private';
|
||||||
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,13 +3,13 @@ import {
|
||||||
Logs,
|
Logs,
|
||||||
TuneWithDetails,
|
TuneWithDetails,
|
||||||
} from '@hyper-tuner/types';
|
} from '@hyper-tuner/types';
|
||||||
import { TuneDbData } from './dbData';
|
import { TuneDbDocument } from './dbData';
|
||||||
|
|
||||||
export interface ConfigState extends Config {}
|
export interface ConfigState extends Config {}
|
||||||
|
|
||||||
export interface TuneState extends TuneWithDetails {}
|
export interface TuneState extends TuneWithDetails {}
|
||||||
|
|
||||||
export interface TuneDataState extends TuneDbData {}
|
export interface TuneDataState extends TuneDbDocument {}
|
||||||
|
|
||||||
export interface LogsState extends Logs {}
|
export interface LogsState extends Logs {}
|
||||||
|
|
||||||
|
|
|
@ -9,17 +9,26 @@ import {
|
||||||
onProgress as onProgressType,
|
onProgress as onProgressType,
|
||||||
} from './http';
|
} from './http';
|
||||||
import TuneParser from './tune/TuneParser';
|
import TuneParser from './tune/TuneParser';
|
||||||
import { TuneDbData } from '../types/dbData';
|
import { TuneDbDocument } from '../types/dbData';
|
||||||
import useServerStorage, { CDN_URL } from '../hooks/useServerStorage';
|
import useServerStorage, { CDN_URL } from '../hooks/useServerStorage';
|
||||||
|
|
||||||
export const loadTune = async (tuneData: TuneDbData) => {
|
// TODO: refactor this!!
|
||||||
|
export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string) => {
|
||||||
|
if (tuneData === null) {
|
||||||
|
store.dispatch({ type: 'config/load', payload: null });
|
||||||
|
store.dispatch({ type: 'tune/load', payload: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pako = await import('pako');
|
const pako = await import('pako');
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const { getFile, getINIFile } = useServerStorage();
|
const { getFileForDownload, getINIFile } = useServerStorage();
|
||||||
|
|
||||||
const started = new Date();
|
const started = new Date();
|
||||||
const tuneRaw = getFile(tuneData.tuneFile!);
|
const tuneRaw = await getFileForDownload(tuneData.tuneFileId!, bucketId);
|
||||||
|
|
||||||
const tuneParser = new TuneParser()
|
const tuneParser = new TuneParser()
|
||||||
.parse(pako.inflate(new Uint8Array(await tuneRaw)));
|
.parse(pako.inflate(new Uint8Array(tuneRaw)));
|
||||||
|
|
||||||
if (!tuneParser.isValid()) {
|
if (!tuneParser.isValid()) {
|
||||||
console.error('Invalid tune');
|
console.error('Invalid tune');
|
||||||
|
@ -29,7 +38,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tune = tuneParser.getTune();
|
const tune = tuneParser.getTune();
|
||||||
const iniRaw = tuneData.customIniFile ? getFile(tuneData.customIniFile) : getINIFile(tune.details.signature);
|
const iniRaw = tuneData.customIniFileId ? getFileForDownload(tuneData.customIniFileId, bucketId) : getINIFile(tuneData.signature);
|
||||||
const buff = pako.inflate(new Uint8Array(await iniRaw));
|
const buff = pako.inflate(new Uint8Array(await iniRaw));
|
||||||
const config = new INI(buff).parse().getResults();
|
const config = new INI(buff).parse().getResults();
|
||||||
|
|
||||||
|
@ -45,7 +54,7 @@ export const loadTune = async (tuneData: TuneDbData) => {
|
||||||
config.constants.pages[0].data.divider = divider;
|
config.constants.pages[0].data.divider = divider;
|
||||||
|
|
||||||
const loadingTimeInfo = `Tune loaded in ${(new Date().getTime() - started.getTime())}ms`;
|
const loadingTimeInfo = `Tune loaded in ${(new Date().getTime() - started.getTime())}ms`;
|
||||||
console.log(loadingTimeInfo);
|
console.info(loadingTimeInfo);
|
||||||
|
|
||||||
store.dispatch({ type: 'config/load', payload: config });
|
store.dispatch({ type: 'config/load', payload: config });
|
||||||
store.dispatch({ type: 'tune/load', payload: tune });
|
store.dispatch({ type: 'tune/load', payload: tune });
|
||||||
|
|
|
@ -8,10 +8,10 @@ export enum Colors {
|
||||||
BLUE = '#2fe3ff',
|
BLUE = '#2fe3ff',
|
||||||
GREY = '#334455',
|
GREY = '#334455',
|
||||||
|
|
||||||
// dark theme
|
// dark theme - keep this in sync with: src/themes/dark.less and common.less
|
||||||
ACCENT = '#1e88ea',
|
ACCENT = '#2F49D1',
|
||||||
TEXT = '#ddd',
|
TEXT = '#CECECE',
|
||||||
MAIN = '#222629',
|
MAIN = '#1E1E1E',
|
||||||
MAIN_DARK = '#191C1E',
|
MAIN_DARK = '#191C1E',
|
||||||
MAIN_LIGHT = '#2E3338',
|
MAIN_LIGHT = '#252525',
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,3 +3,12 @@ export const environment = import.meta.env.VITE_ENVIRONMENT || 'development';
|
||||||
export const isProduction = environment === 'production';
|
export const isProduction = environment === 'production';
|
||||||
export const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
|
export const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
|
||||||
export const platform = `${window.navigator.platform}`;
|
export const platform = `${window.navigator.platform}`;
|
||||||
|
|
||||||
|
export const fetchEnv = (envName: string): string => {
|
||||||
|
const envValue = import.meta.env[envName];
|
||||||
|
if (envValue === '' || envValue === null || envValue === undefined) {
|
||||||
|
throw new Error(`Missing ENV: ${envName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return envValue;
|
||||||
|
};
|
||||||
|
|
|
@ -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 remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||||
|
|
||||||
export const colorHsl = (min: number, max: number, value: number): HslType => {
|
export const colorHsl = (min: number, max: number, value: number): HslType => {
|
||||||
const saturation = 60;
|
const saturation = 80;
|
||||||
const lightness = 40;
|
const lightness = 45;
|
||||||
const coldDeg = 220;
|
const coldDeg = 225;
|
||||||
const hotDeg = 0;
|
const hotDeg = 0;
|
||||||
let hue = remap(value, min, max, coldDeg, hotDeg);
|
let hue = remap(value, min, max, coldDeg, hotDeg);
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,14 @@ class TuneParser {
|
||||||
const raw = (new TextDecoder()).decode(buffer);
|
const raw = (new TextDecoder()).decode(buffer);
|
||||||
const xml = (new DOMParser()).parseFromString(raw, 'text/xml');
|
const xml = (new DOMParser()).parseFromString(raw, 'text/xml');
|
||||||
const xmlPages = xml.getElementsByTagName('page');
|
const xmlPages = xml.getElementsByTagName('page');
|
||||||
const bibliography = xml.getElementsByTagName('bibliography')[0].attributes as any;
|
const bibliography = xml.getElementsByTagName('bibliography')[0]?.attributes as any;
|
||||||
const versionInfo = xml.getElementsByTagName('versionInfo')[0].attributes as any;
|
const versionInfo = xml.getElementsByTagName('versionInfo')[0]?.attributes as any;
|
||||||
|
|
||||||
|
if (!xmlPages || !bibliography || !versionInfo) {
|
||||||
|
this.isTuneValid = false;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
this.tune.details = {
|
this.tune.details = {
|
||||||
author: bibliography.author.value,
|
author: bibliography.author.value,
|
||||||
|
@ -64,7 +70,7 @@ class TuneParser {
|
||||||
this.isTuneValid = true;
|
this.isTuneValid = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.tune.details.signature.match(/^speeduino \d+$/) === null) {
|
if (this.isSignatureSupported()) {
|
||||||
this.isTuneValid = false;
|
this.isTuneValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,6 +84,10 @@ class TuneParser {
|
||||||
isValid(): boolean {
|
isValid(): boolean {
|
||||||
return this.isTuneValid;
|
return this.isTuneValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSignatureSupported(): boolean {
|
||||||
|
return this.tune.details.signature.match(/^speeduino \d+$/) === null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TuneParser;
|
export default TuneParser;
|
||||||
|
|
|
@ -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
|
import { fetchEnv } from './env';
|
||||||
export const generateShareUrl = (tuneId: string) => `${import.meta.env.VITE_WEB_URL}/#/t/${tuneId}`;
|
|
||||||
|
export const buildFullUrl = (parts = [] as string[]) => `${fetchEnv('VITE_WEB_URL')}/#${parts.join('/')}`;
|
||||||
|
|
||||||
|
export const buildRedirectUrl = (page: string) => `${fetchEnv('VITE_WEB_URL')}?redirectPage=${page}`;
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"incremental": false,
|
||||||
|
"noUncheckedIndexedAccess": false
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
|
|
@ -9,14 +9,6 @@ export default defineConfig({
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
firebase: [
|
|
||||||
'firebase/app',
|
|
||||||
'firebase/performance',
|
|
||||||
'firebase/auth',
|
|
||||||
'firebase/analytics',
|
|
||||||
'firebase/storage',
|
|
||||||
'firebase/firestore/lite',
|
|
||||||
],
|
|
||||||
react: ['react', 'react-dom'],
|
react: ['react', 'react-dom'],
|
||||||
antdResult: ['antd/es/result'],
|
antdResult: ['antd/es/result'],
|
||||||
antdTable: ['antd/es/table'],
|
antdTable: ['antd/es/table'],
|
||||||
|
@ -29,7 +21,10 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: { open: true },
|
server: {
|
||||||
|
open: true,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
less: { javascriptEnabled: true },
|
less: { javascriptEnabled: true },
|
||||||
|
|
Loading…
Reference in New Issue