Merge branch 'hyper-tuner:master' into master
This commit is contained in:
commit
7f3023d90f
|
@ -12,6 +12,8 @@ jobs:
|
|||
uses: actions-cool/issues-similarity-analysis@v1
|
||||
with:
|
||||
show-footer: false
|
||||
comment-title: '### Similar issues'
|
||||
comment-body: '${index}. ${similarity} #${number}'
|
||||
show-mentioned: true
|
||||
filter-threshold: 0.6
|
||||
since-days: 730
|
||||
|
|
|
@ -12,22 +12,6 @@ This guide will help you set up this project.
|
|||
cp .env .env.local
|
||||
```
|
||||
|
||||
### Authenticate to GitHub Packages
|
||||
|
||||
Project uses shared packages (`@hyper-tuner/...`).
|
||||
|
||||
They are hosted using `GitHub Packages`, to install them you need to [authenticate to GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages) first.
|
||||
|
||||
#### Personal access token
|
||||
|
||||
Generate GitHub [Personal access token](https://github.com/settings/tokens).
|
||||
|
||||
Private token can be assign to ENV when running `npm install` in the same shell:
|
||||
|
||||
```bash
|
||||
export NPM_GITHUB_TOKEN=YOUR_PRIVATE_GITHUB_TOKEN
|
||||
```
|
||||
|
||||
### Setup correct Node.js version
|
||||
|
||||
```bash
|
||||
|
@ -42,4 +26,10 @@ npm install
|
|||
|
||||
# run development server
|
||||
npm start
|
||||
|
||||
# lint code
|
||||
npm run lint
|
||||
|
||||
# production build
|
||||
npm run build
|
||||
```
|
||||
|
|
|
@ -39,7 +39,12 @@
|
|||
### Speeduino
|
||||
|
||||
- Source code: [noisymime/speeduino](https://github.com/noisymime/speeduino)
|
||||
- Documentation: [wiki.speeduino.com](https://wiki.speeduino.com/)
|
||||
- Documentation: [wiki.speeduino.com](https://wiki.speeduino.com)
|
||||
|
||||
### rusEFI
|
||||
|
||||
- Source code: [rusefi/rusefi](https://github.com/rusefi/rusefi)
|
||||
- Documentation: [wiki.rusefi.com](https://wiki.rusefi.com)
|
||||
|
||||
## Support this project
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
|
@ -20,18 +20,18 @@
|
|||
"typegen": "pocketbase-typegen --json ../pocketbase/pb_schema.json --out src/@types/pocketbase-types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyper-tuner/ini": "^0.6.2",
|
||||
"@hyper-tuner/types": "^0.4.0",
|
||||
"@hyper-tuner/ini": "git+https://github.com/hyper-tuner/ini.git",
|
||||
"@hyper-tuner/types": "git+https://github.com/hyper-tuner/types.git",
|
||||
"@reduxjs/toolkit": "^1.8.6",
|
||||
"@sentry/react": "^7.17.2",
|
||||
"@sentry/tracing": "^7.17.2",
|
||||
"@sentry/react": "^7.17.3",
|
||||
"@sentry/tracing": "^7.17.3",
|
||||
"antd": "^4.23.6",
|
||||
"kbar": "^0.1.0-beta.36",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"mlg-converter": "^0.7.1",
|
||||
"mlg-converter": "^0.8.0",
|
||||
"nanoid": "^4.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"pocketbase": "^0.7.4",
|
||||
"pocketbase": "^0.8.0-rc1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ga4": "^1.4.1",
|
||||
|
@ -41,19 +41,19 @@
|
|||
"react-router-dom": "^6.4.2",
|
||||
"uplot": "^1.6.22",
|
||||
"uplot-react": "^1.1.1",
|
||||
"vite": "^3.2.1"
|
||||
"vite": "^3.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hyper-tuner/eslint-config": "^0.1.6",
|
||||
"@hyper-tuner/eslint-config": "git+https://github.com/hyper-tuner/eslint-config.git",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^18.11.7",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^18.0.24",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"@types/react-redux": "^7.1.24",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.41.0",
|
||||
"@typescript-eslint/parser": "^5.41.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.42.0",
|
||||
"@typescript-eslint/parser": "^5.42.0",
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"eslint": "^8.26.0",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
|
|
|
@ -10,14 +10,14 @@ export type BaseRecord = {
|
|||
id: RecordIdString
|
||||
created: IsoDateString
|
||||
updated: IsoDateString
|
||||
'@collectionId': string
|
||||
'@collectionName': string
|
||||
collectionId: string
|
||||
collectionName: string
|
||||
}
|
||||
|
||||
export enum Collections {
|
||||
IniFiles = 'iniFiles',
|
||||
Profiles = 'profiles',
|
||||
Tunes = 'tunes',
|
||||
Users = 'users',
|
||||
}
|
||||
|
||||
export type IniFilesRecord = {
|
||||
|
@ -26,15 +26,8 @@ export type IniFilesRecord = {
|
|||
ecosystem: 'speeduino' | 'rusefi'
|
||||
}
|
||||
|
||||
export type ProfilesRecord = {
|
||||
userId: UserIdString
|
||||
username: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export type TunesRecord = {
|
||||
user: UserIdString
|
||||
userProfile: RecordIdString
|
||||
author: RecordIdString
|
||||
tuneId: string
|
||||
signature: string
|
||||
vehicleName: string
|
||||
|
@ -59,8 +52,12 @@ export type TunesRecord = {
|
|||
toothLogFiles?: string[]
|
||||
}
|
||||
|
||||
export type UsersRecord = {
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export type CollectionRecords = {
|
||||
iniFiles: IniFilesRecord
|
||||
profiles: ProfilesRecord
|
||||
tunes: TunesRecord
|
||||
users: UsersRecord
|
||||
}
|
||||
|
|
|
@ -63,6 +63,7 @@ const ResetPasswordConfirmation = lazy(() => import('./pages/auth/ResetPasswordC
|
|||
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
|
||||
const OauthCallback = lazy(() => import('./pages/auth/OauthCallback'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
const User = lazy(() => import('./pages/User'));
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
|
@ -202,6 +203,7 @@ const App = ({ ui, tuneData }: { ui: UIState, tuneData: TuneDataState }) => {
|
|||
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
|
||||
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
|
||||
<Route path={Routes.ABOUT} element={<ContentFor element={<About />} />} />
|
||||
<Route path={Routes.USER_ROOT} element={<ContentFor element={<User />} />} />
|
||||
|
||||
<Route path={Routes.EMAIL_VERIFICATION} element={<ContentFor element={<EmailVerification />} />} />
|
||||
<Route path={Routes.RESET_PASSWORD_CONFIRMATION} element={<ContentFor element={<ResetPasswordConfirmation />} />} />
|
||||
|
|
|
@ -54,7 +54,8 @@ import {
|
|||
NavigationState,
|
||||
} from '../types/state';
|
||||
import {
|
||||
buildUrl,
|
||||
buildDialogUrl,
|
||||
buildGroupMenuDialogUrl,
|
||||
SKIP_MENUS,
|
||||
SKIP_SUB_MENUS,
|
||||
} from './Tune/SideBar';
|
||||
|
@ -261,7 +262,12 @@ const ActionsProvider = (props: CommandPaletteProps) => {
|
|||
},
|
||||
];
|
||||
|
||||
const mapSubMenuItems = (rootMenuName: string, rootMenu: MenuType, subMenus: { [name: string]: SubMenuType | GroupMenuType | GroupChildMenuType }) => {
|
||||
const mapSubMenuItems = (
|
||||
rootMenuName: string,
|
||||
rootMenu: MenuType,
|
||||
subMenus: { [name: string]: SubMenuType | GroupMenuType | GroupChildMenuType },
|
||||
groupMenuName: string | null = null,
|
||||
) => {
|
||||
Object
|
||||
.keys(subMenus)
|
||||
.forEach((subMenuName: string) => {
|
||||
|
@ -276,17 +282,22 @@ const ActionsProvider = (props: CommandPaletteProps) => {
|
|||
const subMenu = subMenus[subMenuName];
|
||||
|
||||
if ((subMenu as GroupMenuType).type === 'groupMenu') {
|
||||
mapSubMenuItems(rootMenuName, rootMenu, (subMenu as GroupMenuType).groupChildMenus);
|
||||
// recurrence
|
||||
mapSubMenuItems(rootMenuName, rootMenu, (subMenu as GroupMenuType).groupChildMenus, (subMenu as GroupMenuType).title);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const url = groupMenuName ?
|
||||
buildGroupMenuDialogUrl(navigation.tuneId!, rootMenuName, groupMenuName, subMenuName) :
|
||||
buildDialogUrl(navigation.tuneId!, rootMenuName, subMenuName);
|
||||
|
||||
newActions.push({
|
||||
id: buildUrl(navigation.tuneId!, rootMenuName, subMenuName),
|
||||
id: url,
|
||||
section: rootMenu.title,
|
||||
name: subMenu.title,
|
||||
icon: <Icon name={subMenuName} />,
|
||||
perform: () => navigate(buildUrl(navigation.tuneId!, rootMenuName, subMenuName)),
|
||||
perform: () => navigate(url),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -373,6 +384,13 @@ const CommandPalette = (props: CommandPaletteProps) => {
|
|||
icon: <LogoutOutlined />,
|
||||
perform: logoutAction,
|
||||
},
|
||||
{
|
||||
id: 'AboutAction',
|
||||
name: 'About',
|
||||
subtitle: 'About this app / sponsor.',
|
||||
icon: <InfoCircleOutlined />,
|
||||
perform: () => navigate(Routes.ABOUT),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,7 +8,7 @@ import UplotReact from 'uplot-react';
|
|||
import uPlot from 'uplot';
|
||||
import {
|
||||
colorHsl,
|
||||
formatNumber,
|
||||
formatNumberMs,
|
||||
} from '../../utils/numbers';
|
||||
import LandscapeNotice from '../Tune/Dialog/LandscapeNotice';
|
||||
import { Colors } from '../../utils/colors';
|
||||
|
@ -88,7 +88,7 @@ const LogCanvas = ({ data, width, height, selectedFields1, selectedFields2, show
|
|||
stroke: hsl(index, selectedFieldsLength),
|
||||
scale: field.units,
|
||||
width: 2,
|
||||
value: (_self, val) => `${(isNumber(val) ? formatNumber(val, field.format) : 0)}${field.units}`,
|
||||
value: (_self, val) => `${(isNumber(val) ? formatNumberMs(val, field.format) : 0)}${field.units}`,
|
||||
});
|
||||
|
||||
data.forEach((entry) => {
|
||||
|
|
|
@ -58,6 +58,8 @@ import { logOutSuccessful } from '../pages/auth/notifications';
|
|||
import { TuneDataState } from '../types/state';
|
||||
import { removeFilenameSuffix } from '../pocketbase';
|
||||
import useServerStorage from '../hooks/useServerStorage';
|
||||
import useDb from '../hooks/useDb';
|
||||
import { Collections } from '../@types/pocketbase-types';
|
||||
|
||||
const { Header } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
@ -88,11 +90,12 @@ const TopBar = ({
|
|||
const uploadMatch = useMatch(Routes.UPLOAD);
|
||||
const hubMatch = useMatch(Routes.HUB);
|
||||
const { downloadFile } = useServerStorage();
|
||||
const { getIni } = useDb();
|
||||
const downloadAnchorRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const logoutClick = useCallback(() => {
|
||||
logout();
|
||||
logOutSuccessful();
|
||||
navigate(Routes.HUB);
|
||||
navigate(0);
|
||||
}, [logout, navigate]);
|
||||
|
||||
const toggleCommandPalette = useCallback(() => query.toggle(), [query]);
|
||||
|
@ -112,7 +115,7 @@ const TopBar = ({
|
|||
key: filename,
|
||||
label: removeFilenameSuffix(filename),
|
||||
icon: logsExtensionsIcons[filename.slice(-3)],
|
||||
onClick: () => downloadFile(tuneData!.id, filename, downloadAnchorRef.current!),
|
||||
onClick: () => downloadFile(Collections.Tunes, tuneData!.id, filename, downloadAnchorRef.current!),
|
||||
})),
|
||||
};
|
||||
|
||||
|
@ -124,7 +127,7 @@ const TopBar = ({
|
|||
key: filename,
|
||||
label: removeFilenameSuffix(filename),
|
||||
icon: logsExtensionsIcons[filename.slice(-3)],
|
||||
onClick: () => downloadFile(tuneData!.id, filename, downloadAnchorRef.current!),
|
||||
onClick: () => downloadFile(Collections.Tunes, tuneData!.id, filename, downloadAnchorRef.current!),
|
||||
})),
|
||||
};
|
||||
|
||||
|
@ -138,7 +141,7 @@ const TopBar = ({
|
|||
label: 'Download',
|
||||
icon: <FileTextOutlined />,
|
||||
key: 'download',
|
||||
onClick: () => downloadFile(tuneData!.id, tuneData!.tuneFile, downloadAnchorRef.current!),
|
||||
onClick: () => downloadFile(Collections.Tunes, tuneData!.id, tuneData!.tuneFile, downloadAnchorRef.current!),
|
||||
},
|
||||
{
|
||||
label: 'Open in app',
|
||||
|
@ -149,6 +152,19 @@ const TopBar = ({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'INI',
|
||||
icon: <FileTextOutlined />,
|
||||
key: 'ini',
|
||||
onClick: async () => {
|
||||
if (tuneData?.customIniFile) {
|
||||
downloadFile(Collections.Tunes, tuneData!.id, tuneData!.customIniFile, downloadAnchorRef.current!);
|
||||
} else {
|
||||
const ini = await getIni(tuneData!.signature);
|
||||
downloadFile(Collections.IniFiles, ini!.id, ini!.file, downloadAnchorRef.current!);
|
||||
}
|
||||
},
|
||||
},
|
||||
(tuneData?.logFiles || []).length > 0 ? { ...downloadLogsItems } : null,
|
||||
(tuneData?.toothLogFiles || []).length > 0 ? { ...downloadToothLogsItems } : null,
|
||||
];
|
||||
|
|
|
@ -145,6 +145,11 @@ const Dialog = ({
|
|||
const y = tune.constants[table.yBins[0]];
|
||||
const z = tune.constants[table.zBins[0]];
|
||||
|
||||
if (!x || !y) {
|
||||
// TODO: handle this (rusEFI: fuel/lambdaTableTbl)
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>
|
||||
{renderHelp(table.help)}
|
||||
<Map
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
InputNumber,
|
||||
Slider,
|
||||
} from 'antd';
|
||||
import { formatNumber } from '../../../utils/numbers';
|
||||
|
||||
const SmartNumber = ({
|
||||
defaultValue,
|
||||
|
@ -22,6 +23,7 @@ const SmartNumber = ({
|
|||
.includes(`${u}`.toUpperCase());
|
||||
const sliderMarks: { [value: number]: string } = {};
|
||||
const step = digits ? 10**-digits : 1;
|
||||
const value = formatNumber(defaultValue, digits);
|
||||
sliderMarks[min] = `${min}${units}`;
|
||||
|
||||
if (min <= 0) {
|
||||
|
@ -35,7 +37,7 @@ const SmartNumber = ({
|
|||
if (isSlider(units || '')) {
|
||||
return (
|
||||
<Slider
|
||||
value={defaultValue}
|
||||
value={value as unknown as number}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
@ -50,7 +52,7 @@ const SmartNumber = ({
|
|||
|
||||
return (
|
||||
<InputNumber
|
||||
value={defaultValue}
|
||||
value={value as unknown as number}
|
||||
precision={digits}
|
||||
min={min}
|
||||
max={max}
|
||||
|
|
|
@ -48,10 +48,17 @@ export const SKIP_SUB_MENUS = [
|
|||
'tuning/std_realtime',
|
||||
];
|
||||
|
||||
export const buildUrl = (tuneId: string, main: string, sub: string) => generatePath(Routes.TUNE_DIALOG, {
|
||||
export const buildDialogUrl = (tuneId: string, main: string, dialog: string) => generatePath(Routes.TUNE_DIALOG, {
|
||||
tuneId,
|
||||
category: main,
|
||||
dialog: sub,
|
||||
dialog: dialog.replaceAll(' ', '-'),
|
||||
});
|
||||
|
||||
export const buildGroupMenuDialogUrl = (tuneId: string, main: string, groupMenu: string, dialog: string) => generatePath(Routes.TUNE_GROUP_MENU_DIALOG, {
|
||||
tuneId,
|
||||
category: main,
|
||||
groupMenu: groupMenu.replaceAll(' ', '-'),
|
||||
dialog,
|
||||
});
|
||||
|
||||
const mapStateToProps = (state: AppState) => ({
|
||||
|
@ -66,13 +73,14 @@ interface SideBarProps {
|
|||
tune: TuneType | null;
|
||||
ui: UIState;
|
||||
navigation: NavigationState;
|
||||
matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'>;
|
||||
matchedPath: PathMatch<'dialog' | 'tuneId' | 'category'> | null;
|
||||
matchedGroupMenuDialogPath: PathMatch<'dialog' | 'groupMenu' | 'tuneId' | 'category'> | null;
|
||||
};
|
||||
|
||||
export const sidebarWidth = 250;
|
||||
export const collapsedSidebarWidth = 50;
|
||||
|
||||
const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) => {
|
||||
const SideBar = ({ config, tune, ui, navigation, matchedPath, matchedGroupMenuDialogPath }: SideBarProps) => {
|
||||
const siderProps = {
|
||||
width: sidebarWidth,
|
||||
collapsedWidth: collapsedSidebarWidth,
|
||||
|
@ -84,7 +92,11 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
|
|||
const [menus, setMenus] = useState<ItemType[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const mapSubMenuItems = useCallback((rootMenuName: string, subMenus: { [name: string]: SubMenuType | GroupMenuType | GroupChildMenuType }): ItemType[] => {
|
||||
const mapSubMenuItems = useCallback((
|
||||
rootMenuName: string,
|
||||
subMenus: { [name: string]: SubMenuType | GroupMenuType | GroupChildMenuType },
|
||||
groupMenuName: string | null = null,
|
||||
): ItemType[] => {
|
||||
const items: ItemType[] = [];
|
||||
|
||||
Object
|
||||
|
@ -105,16 +117,26 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
|
|||
const subMenu = subMenus[subMenuName];
|
||||
|
||||
if ((subMenu as GroupMenuType).type === 'groupMenu') {
|
||||
items.push(...mapSubMenuItems(rootMenuName, (subMenu as GroupMenuType).groupChildMenus));
|
||||
// recurrence
|
||||
items.push({
|
||||
key: buildDialogUrl(navigation.tuneId!, rootMenuName, (subMenu as GroupMenuType).title),
|
||||
icon: <Icon name={subMenuName} />,
|
||||
label: (subMenu as GroupMenuType).title,
|
||||
children: mapSubMenuItems(rootMenuName, (subMenu as GroupMenuType).groupChildMenus, (subMenu as GroupMenuType).title),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const url = groupMenuName ?
|
||||
buildGroupMenuDialogUrl(navigation.tuneId!, rootMenuName, groupMenuName, subMenuName) :
|
||||
buildDialogUrl(navigation.tuneId!, rootMenuName, subMenuName);
|
||||
|
||||
items.push({
|
||||
key: buildUrl(navigation.tuneId!, rootMenuName, subMenuName),
|
||||
key: url,
|
||||
icon: <Icon name={subMenuName} />,
|
||||
label: subMenu.title,
|
||||
onClick: () => navigate(buildUrl(navigation.tuneId!, rootMenuName, subMenuName)),
|
||||
onClick: () => navigate(url),
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -144,15 +166,30 @@ const SideBar = ({ config, tune, ui, navigation, matchedPath }: SideBarProps) =>
|
|||
}
|
||||
}, [config, config?.menus, menusList, tune, tune?.constants]);
|
||||
|
||||
const defaultOpenSubmenus = () => {
|
||||
if (matchedGroupMenuDialogPath) {
|
||||
return [
|
||||
`/${matchedGroupMenuDialogPath.params.category}`,
|
||||
buildDialogUrl(
|
||||
navigation.tuneId!,
|
||||
matchedGroupMenuDialogPath.params.category!,
|
||||
matchedGroupMenuDialogPath.params.groupMenu!,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [`/${matchedPath!.params.category}`];
|
||||
};
|
||||
|
||||
return (
|
||||
<Sider {...siderProps} className="app-sidebar">
|
||||
<PerfectScrollbar options={{ suppressScrollX: true }}>
|
||||
<Menu
|
||||
defaultSelectedKeys={[matchedPath.pathname]}
|
||||
defaultOpenKeys={ui.sidebarCollapsed ? [] : [`/${matchedPath.params.category}`]}
|
||||
defaultSelectedKeys={[matchedGroupMenuDialogPath ? matchedGroupMenuDialogPath.pathname : matchedPath!.pathname]}
|
||||
defaultOpenKeys={ui.sidebarCollapsed ? [] : defaultOpenSubmenus()}
|
||||
mode="inline"
|
||||
style={{ height: '100%' }}
|
||||
key={matchedPath.pathname}
|
||||
key={matchedGroupMenuDialogPath ? matchedGroupMenuDialogPath.pathname : matchedPath!.pathname}
|
||||
items={menus}
|
||||
/>
|
||||
</PerfectScrollbar>
|
||||
|
|
|
@ -9,11 +9,11 @@ import {
|
|||
import {
|
||||
client,
|
||||
formatError,
|
||||
User,
|
||||
} from '../pocketbase';
|
||||
import { buildRedirectUrl } from '../utils/url';
|
||||
import { Collections } from '../@types/pocketbase-types';
|
||||
import { Routes } from '../routes';
|
||||
import { UsersRecordFull } from '../types/dbData';
|
||||
|
||||
// TODO: this should be imported from pocketbase but currently is not exported
|
||||
export type AuthProviderInfo = {
|
||||
|
@ -39,10 +39,10 @@ export enum OAuthProviders {
|
|||
};
|
||||
|
||||
interface AuthValue {
|
||||
currentUser: User | null,
|
||||
signUp: (email: string, password: string) => Promise<User>,
|
||||
login: (email: string, password: string) => Promise<User>,
|
||||
refreshUser: () => Promise<User | null>,
|
||||
currentUser: UsersRecordFull | null,
|
||||
signUp: (email: string, password: string, username: string) => Promise<UsersRecordFull>,
|
||||
login: (email: string, password: string) => Promise<UsersRecordFull>,
|
||||
refreshUser: () => Promise<UsersRecordFull | null>,
|
||||
sendEmailVerification: () => Promise<void>,
|
||||
confirmEmailVerification: (token: string) => Promise<void>,
|
||||
confirmResetPassword: (token: string, password: string) => Promise<void>,
|
||||
|
@ -57,21 +57,24 @@ const AuthContext = createContext<AuthValue | null>(null);
|
|||
|
||||
const useAuth = () => useContext<AuthValue>(AuthContext as any);
|
||||
|
||||
const users = client.collection(Collections.Users);
|
||||
|
||||
const AuthProvider = (props: { children: ReactNode }) => {
|
||||
const { children } = props;
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<UsersRecordFull | null>(null);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
currentUser,
|
||||
signUp: async (email: string, password: string) => {
|
||||
signUp: async (email: string, password: string, username: string) => {
|
||||
try {
|
||||
const user = await client.users.create({
|
||||
const user = await users.create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
username,
|
||||
});
|
||||
client.users.requestVerification(user.email);
|
||||
await client.users.authViaEmail(user.email, password);
|
||||
users.requestVerification(email);
|
||||
await users.authWithPassword(email, password);
|
||||
|
||||
return Promise.resolve(user);
|
||||
} catch (error) {
|
||||
|
@ -80,16 +83,16 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
login: async (email: string, password: string) => {
|
||||
try {
|
||||
const authResponse = await client.users.authViaEmail(email, password);
|
||||
return Promise.resolve(authResponse.user);
|
||||
const authResponse = await users.authWithPassword(email, password);
|
||||
return Promise.resolve(authResponse.record);
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
refreshUser: async () => {
|
||||
try {
|
||||
const authResponse = await client.users.refresh();
|
||||
return Promise.resolve(authResponse.user);
|
||||
const authResponse = await users.authRefresh();
|
||||
return Promise.resolve(authResponse.record);
|
||||
} catch (error) {
|
||||
client.authStore.clear();
|
||||
return Promise.resolve(null);
|
||||
|
@ -97,7 +100,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
sendEmailVerification: async () => {
|
||||
try {
|
||||
await client.users.requestVerification(currentUser!.email);
|
||||
await users.requestVerification(currentUser!.email);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
|
@ -105,7 +108,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
confirmEmailVerification: async (token: string) => {
|
||||
try {
|
||||
await client.users.confirmVerification(token);
|
||||
await users.confirmVerification(token);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
|
@ -113,7 +116,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
confirmResetPassword: async (token: string, password: string) => {
|
||||
try {
|
||||
await client.users.confirmPasswordReset(token, password, password);
|
||||
await users.confirmPasswordReset(token, password, password);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
|
@ -124,7 +127,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
initResetPassword: async (email: string) => {
|
||||
try {
|
||||
await client.users.requestPasswordReset(email);
|
||||
await users.requestPasswordReset(email);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
|
@ -132,14 +135,14 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
listAuthMethods: async () => {
|
||||
try {
|
||||
const methods = await client.users.listAuthMethods();
|
||||
const methods = await users.listAuthMethods();
|
||||
return Promise.resolve(methods);
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
oAuth: async (provider: OAuthProviders, code: string, codeVerifier: string) => {
|
||||
client.users.authViaOAuth2(
|
||||
users.authWithOAuth2(
|
||||
provider,
|
||||
code,
|
||||
codeVerifier,
|
||||
|
@ -148,7 +151,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
},
|
||||
updateUsername: async (username: string) => {
|
||||
try {
|
||||
await client.records.update(Collections.Profiles, currentUser!.profile!.id, {
|
||||
await client.collection(Collections.Users).update(currentUser!.id, {
|
||||
username,
|
||||
});
|
||||
return Promise.resolve();
|
||||
|
@ -159,24 +162,14 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
}), [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentUser(client.authStore.model as User | null);
|
||||
setCurrentUser(client.authStore.model as UsersRecordFull | null);
|
||||
|
||||
const storeUnsubscribe = client.authStore.onChange((_token, model) => {
|
||||
setCurrentUser(model as User | null);
|
||||
});
|
||||
|
||||
client.realtime.subscribe(Collections.Tunes, (event) => {
|
||||
console.info('Tunes event', event);
|
||||
});
|
||||
|
||||
client.realtime.subscribe(Collections.Profiles, (event) => {
|
||||
console.info('Profiles event', event);
|
||||
setCurrentUser(model as UsersRecordFull | null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
storeUnsubscribe();
|
||||
client.realtime.unsubscribe(Collections.Tunes);
|
||||
client.realtime.unsubscribe(Collections.Profiles);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -11,7 +11,8 @@ body {
|
|||
background-color: @main-dark;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
|
@ -172,7 +173,8 @@ select:-webkit-autofill:focus {
|
|||
font-size: 2em;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
code,
|
||||
pre {
|
||||
background-color: @main;
|
||||
border-radius: @border-radius-base;
|
||||
}
|
||||
|
@ -195,3 +197,14 @@ select:-webkit-autofill:focus {
|
|||
.sponsor-button {
|
||||
animation: wiggle 2s linear 1;
|
||||
}
|
||||
|
||||
.ant-table-row,
|
||||
.ant-list-item {
|
||||
&.unlisted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,10 +15,13 @@ import {
|
|||
TunesRecord,
|
||||
} from '../@types/pocketbase-types';
|
||||
|
||||
const tunesCollection = client.collection(Collections.Tunes);
|
||||
const iniFilesCollection = client.collection(Collections.IniFiles);
|
||||
|
||||
const useDb = () => {
|
||||
const updateTune = async (id: string, data: TunesRecordPartial) => {
|
||||
try {
|
||||
await client.records.update(Collections.Tunes, id, data);
|
||||
await tunesCollection.update(id, data);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
|
@ -30,7 +33,7 @@ const useDb = () => {
|
|||
|
||||
const createTune = async (data: TunesRecord) => {
|
||||
try {
|
||||
const record = await client.records.create(Collections.Tunes, data);
|
||||
const record = await tunesCollection.create(data);
|
||||
|
||||
return Promise.resolve(record as TunesRecordFull);
|
||||
} catch (error) {
|
||||
|
@ -43,17 +46,23 @@ const useDb = () => {
|
|||
|
||||
const getTune = async (tuneId: string) => {
|
||||
try {
|
||||
const tune = await client.records.getList(Collections.Tunes, 1, 1, {
|
||||
filter: `tuneId = "${tuneId}"`,
|
||||
expand: 'userProfile',
|
||||
});
|
||||
const tune = await tunesCollection.getFirstListItem(
|
||||
`tuneId = "${tuneId}"`,
|
||||
{
|
||||
expand: 'author',
|
||||
},
|
||||
);
|
||||
|
||||
return Promise.resolve(tune.totalItems > 0 ? tune.items[0] as TunesRecordFull : null);
|
||||
return Promise.resolve(tune as TunesRecordFull);
|
||||
} catch (error) {
|
||||
if ((error as ClientResponseError).isAbort) {
|
||||
return Promise.reject(new Error('Cancelled'));
|
||||
}
|
||||
|
||||
if ((error as ClientResponseError).status === 404) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
|
@ -63,11 +72,43 @@ const useDb = () => {
|
|||
|
||||
const getIni = async (signature: string) => {
|
||||
try {
|
||||
const tune = await client.records.getList(Collections.IniFiles, 1, 1, {
|
||||
filter: `signature = "${signature}"`,
|
||||
const ini = await iniFilesCollection.getFirstListItem(`signature = "${signature}"`);
|
||||
|
||||
return Promise.resolve(ini as IniFilesRecordFull);
|
||||
} catch (error) {
|
||||
if ((error as ClientResponseError).isAbort) {
|
||||
return Promise.reject(new Error('Cancelled'));
|
||||
}
|
||||
|
||||
if ((error as ClientResponseError).status === 404) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
Sentry.captureException(error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const searchTunes = async (search: string, page: number, perPage: number) => {
|
||||
const phrases = search.length > 0 ? search.replace(/ +(?= )/g, '').split(' ') : [];
|
||||
const filter = phrases
|
||||
.filter((phrase) => phrase.length > 1)
|
||||
.map((phrase) => `textSearch ~ "${phrase}" || author.username ~ "${phrase}"`)
|
||||
.join(' && ');
|
||||
|
||||
try {
|
||||
const list = await tunesCollection.getList(page, perPage, {
|
||||
sort: '-updated',
|
||||
filter,
|
||||
expand: 'author',
|
||||
});
|
||||
|
||||
return Promise.resolve(tune.totalItems > 0 ? tune.items[0] as IniFilesRecordFull : null);
|
||||
return Promise.resolve({
|
||||
items: list.items as TunesRecordFull[],
|
||||
totalItems: list.totalItems,
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as ClientResponseError).isAbort) {
|
||||
return Promise.reject(new Error('Cancelled'));
|
||||
|
@ -80,18 +121,12 @@ const useDb = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const searchTunes = async (search: string, page: number, perPage: number) => {
|
||||
const phrases = search.length > 0 ? search.replace(/ +(?= )/g,'').split(' ') : [];
|
||||
const filter = phrases
|
||||
.filter((phrase) => phrase.length > 1)
|
||||
.map((phrase) => `textSearch ~ "${phrase}"`)
|
||||
.join(' || ');
|
||||
|
||||
const getUserTunes = async (userId: string, page: number, perPage: number) => {
|
||||
try {
|
||||
const list = await client.records.getList(Collections.Tunes, page, perPage, {
|
||||
const list = await tunesCollection.getList(page, perPage, {
|
||||
sort: '-updated',
|
||||
filter,
|
||||
expand: 'userProfile',
|
||||
filter: `author = "${userId}"`,
|
||||
expand: 'author',
|
||||
});
|
||||
|
||||
return Promise.resolve({
|
||||
|
@ -112,7 +147,7 @@ const useDb = () => {
|
|||
|
||||
const autocomplete = async (attribute: string, search: string) => {
|
||||
try {
|
||||
const items = await client.records.getFullList(Collections.Tunes, 10, {
|
||||
const items = await tunesCollection.getFullList(10, {
|
||||
filter: `${attribute} ~ "${search}"`,
|
||||
});
|
||||
|
||||
|
@ -135,6 +170,7 @@ const useDb = () => {
|
|||
getTune: (tuneId: string): Promise<TunesRecordFull | null> => getTune(tuneId),
|
||||
getIni: (tuneId: string): Promise<IniFilesRecordFull | null> => getIni(tuneId),
|
||||
searchTunes: (search: string, page: number, perPage: number): Promise<{ items: TunesRecordFull[]; totalItems: number }> => searchTunes(search, page, perPage),
|
||||
getUserTunes: (userId: string, page: number, perPage: number): Promise<{ items: TunesRecordFull[]; totalItems: number }> => getUserTunes(userId, page, perPage),
|
||||
autocomplete: (attribute: string, search: string): Promise<TunesRecordFull[]> => autocomplete(attribute, search),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -47,10 +47,10 @@ const useServerStorage = () => {
|
|||
signal,
|
||||
).then((response) => response);
|
||||
|
||||
const downloadFile = async (recordId: string, filename: string, anchorRef: HTMLAnchorElement) => {
|
||||
const downloadFile = async (collection: Collections, recordId: string, filename: string, anchorRef: HTMLAnchorElement) => {
|
||||
downloading();
|
||||
|
||||
const response = await fetch(buildFileUrl(Collections.Tunes, recordId, filename));
|
||||
const response = await fetch(buildFileUrl(collection, recordId, filename));
|
||||
const data = Pako.inflate(new Uint8Array(await response.arrayBuffer()));
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
|
||||
|
@ -68,7 +68,7 @@ const useServerStorage = () => {
|
|||
fetchTuneFile: (recordId: string, filename: string): Promise<ArrayBuffer> => fetchTuneFile(recordId, filename),
|
||||
fetchINIFile: (signature: string): Promise<ArrayBuffer> => fetchINIFile(signature),
|
||||
fetchLogFileWithProgress: (recordId: string, filename: string, onProgress?: OnProgress, signal?: AbortSignal): Promise<ArrayBuffer> => fetchLogFileWithProgress(recordId, filename, onProgress, signal),
|
||||
downloadFile: (recordId: string, filename: string, anchorRef: HTMLAnchorElement): Promise<void> => downloadFile(recordId, filename, anchorRef),
|
||||
downloadFile: (collection: Collections, recordId: string, filename: string, anchorRef: HTMLAnchorElement): Promise<void> => downloadFile(collection, recordId, filename, anchorRef),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
|
@ -13,6 +14,7 @@ import {
|
|||
CopyOutlined,
|
||||
StarOutlined,
|
||||
ArrowRightOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
useCallback,
|
||||
|
@ -33,10 +35,13 @@ import {
|
|||
copyToClipboard,
|
||||
isClipboardSupported,
|
||||
} from '../utils/clipboard';
|
||||
import { ProfilesRecord } from '../@types/pocketbase-types';
|
||||
import { isEscape } from '../utils/keyboard/shortcuts';
|
||||
import { TunesRecordFull } from '../types/dbData';
|
||||
import { formatTime } from '../pocketbase';
|
||||
import {
|
||||
TunesRecordFull,
|
||||
UsersRecordFull,
|
||||
} from '../types/dbData';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
const { Text, Title } = Typography;
|
||||
|
@ -47,13 +52,17 @@ const Hub = () => {
|
|||
const { xs } = useBreakpoint();
|
||||
const { searchTunes } = useDb();
|
||||
const navigate = useNavigate();
|
||||
const [dataSource, setDataSource] = useState<{}[]>([]); // TODO: fix this type
|
||||
const [dataSource, setDataSource] = useState<TunesRecordFull[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const searchRef = useRef<InputRef | null>(null);
|
||||
const { currentUser } = useAuth();
|
||||
const goToEdit = (tuneId: string) => navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
|
||||
tuneId,
|
||||
}));
|
||||
|
||||
const loadData = debounce(async (searchText: string) => {
|
||||
setIsLoading(true);
|
||||
|
@ -64,13 +73,13 @@ const Hub = () => {
|
|||
...tune,
|
||||
key: tune.tuneId,
|
||||
year: tune.year,
|
||||
author: (tune['@expand'] as { userProfile: ProfilesRecord }).userProfile.username,
|
||||
authorUsername: (tune.expand.author as unknown as UsersRecordFull).username,
|
||||
displacement: `${tune.displacement}l`,
|
||||
aspiration: aspirationMapper[tune.aspiration],
|
||||
published: formatTime(tune.updated),
|
||||
stars: 0,
|
||||
}));
|
||||
setDataSource(mapped);
|
||||
setDataSource(mapped as any);
|
||||
} catch (error) {
|
||||
// request cancelled
|
||||
} finally {
|
||||
|
@ -81,7 +90,7 @@ const Hub = () => {
|
|||
const debounceLoadData = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
loadData(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleGlobalKeyboard = useCallback((e: KeyboardEvent) => {
|
||||
|
@ -109,7 +118,11 @@ const Hub = () => {
|
|||
<>
|
||||
<Title level={5}>{tune.vehicleName}</Title>
|
||||
<Space direction="vertical">
|
||||
<Text type="secondary">{tune.author}, {tune.published}</Text>
|
||||
<Text type="secondary">
|
||||
<Link to={generatePath(Routes.USER_ROOT, { userId: tune.author })}>
|
||||
{tune.authorUsername}
|
||||
</Link>, {tune.published}
|
||||
</Text>
|
||||
<Text>{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.cylindersCount} cylinders, {tune.aspiration}</Text>
|
||||
<Text code>{tune.signature}</Text>
|
||||
</Space>
|
||||
|
@ -136,7 +149,7 @@ const Hub = () => {
|
|||
responsive: ['sm'],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
title: 'Displacement',
|
||||
dataIndex: 'displacement',
|
||||
key: 'displacement',
|
||||
responsive: ['sm'],
|
||||
|
@ -155,9 +168,14 @@ const Hub = () => {
|
|||
},
|
||||
{
|
||||
title: 'Author',
|
||||
dataIndex: 'author',
|
||||
key: 'author',
|
||||
dataIndex: 'authorUsername',
|
||||
key: 'authorUsername',
|
||||
responsive: ['sm'],
|
||||
render: (userName: string, record: TunesRecordFull) => (
|
||||
<Link to={generatePath(Routes.USER_ROOT, { userId: record.author })}>
|
||||
{userName}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Signature',
|
||||
|
@ -180,12 +198,18 @@ const Hub = () => {
|
|||
{
|
||||
dataIndex: 'tuneId',
|
||||
fixed: 'right',
|
||||
render: (tuneId: string) => (
|
||||
<Space>
|
||||
{isClipboardSupported && <Button icon={<CopyOutlined />} onClick={() => copyToClipboard(buildFullUrl([tunePath(tuneId)]))} />}
|
||||
<Button type="primary" icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tuneId))} />
|
||||
</Space>
|
||||
),
|
||||
render: (tuneId: string, record: TunesRecordFull) => {
|
||||
const isOwner = currentUser?.id === record.author;
|
||||
const size = isOwner ? 'small' : 'middle';
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{isOwner && <Button size={size} icon={<EditOutlined />} onClick={() => goToEdit(tuneId)} />}
|
||||
{isClipboardSupported && <Button size={size} icon={<CopyOutlined />} onClick={() => copyToClipboard(buildFullUrl([tunePath(tuneId)]))} />}
|
||||
<Button size={size} type="primary" icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tuneId))} />
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
key: 'tuneId',
|
||||
},
|
||||
];
|
||||
|
@ -222,6 +246,7 @@ const Hub = () => {
|
|||
loading={isLoading}
|
||||
scroll={xs ? undefined : { x: 1360 }}
|
||||
pagination={false}
|
||||
rowClassName={(tune) => tune.visibility}
|
||||
/>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Pagination
|
||||
|
|
|
@ -21,7 +21,8 @@ import {
|
|||
import Loader from '../components/Loader';
|
||||
import { Routes } from '../routes';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatTime } from '../pocketbase';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { UsersRecordFull } from '../types/dbData';
|
||||
|
||||
const { Item } = Form;
|
||||
const rowProps = { gutter: 10 };
|
||||
|
@ -39,7 +40,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
|||
tuneId: tuneData.tuneId,
|
||||
}));
|
||||
|
||||
const canManage = currentUser && tuneData && currentUser.id === tuneData.user;
|
||||
const canManage = currentUser && tuneData && currentUser.id === tuneData.author;
|
||||
|
||||
const manageSection = (
|
||||
<>
|
||||
|
@ -70,7 +71,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
|||
<Row {...rowProps}>
|
||||
<Col {...colProps}>
|
||||
<Item>
|
||||
<Input value={tuneData['@expand'].userProfile.username} addonBefore="Author" />
|
||||
<Input value={(tuneData.expand.author as unknown as UsersRecordFull).username} addonBefore="Author" />
|
||||
</Item>
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
|
|
|
@ -26,8 +26,7 @@ const mapStateToProps = (state: AppState) => ({
|
|||
const Tune = ({ config, tune }: { config: ConfigType | null, tune: TuneState }) => {
|
||||
const dialogMatch = useMatch(Routes.TUNE_DIALOG);
|
||||
const tuneRootMatch = useMatch(Routes.TUNE_TUNE);
|
||||
// const { storageGetSync } = useBrowserStorage();
|
||||
// const lastDialogPath = storageGetSync('lastDialog');
|
||||
const groupMenuDialogMatch = useMatch(Routes.TUNE_GROUP_MENU_DIALOG);
|
||||
const { isConfigReady } = useConfig(config);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -47,18 +46,21 @@ const Tune = ({ config, tune }: { config: ConfigType | null, tune: TuneState })
|
|||
|
||||
navigate(firstDialogPath, { replace: true });
|
||||
}
|
||||
}, [navigate, tuneRootMatch, isConfigReady, config?.menus, tuneId, config, tune, dialogMatch]);
|
||||
}, [navigate, tuneRootMatch, isConfigReady, config?.menus, tuneId, config, tune, dialogMatch, groupMenuDialogMatch]);
|
||||
|
||||
if (!tune || !config || !dialogMatch) {
|
||||
if (!tune || !config || (!dialogMatch && !groupMenuDialogMatch)) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideBar matchedPath={dialogMatch!} />
|
||||
<SideBar
|
||||
matchedPath={dialogMatch!}
|
||||
matchedGroupMenuDialogPath={groupMenuDialogMatch}
|
||||
/>
|
||||
<Dialog
|
||||
name={dialogMatch?.params.dialog!}
|
||||
url={dialogMatch?.pathname || ''}
|
||||
name={groupMenuDialogMatch ? groupMenuDialogMatch.params.dialog! : dialogMatch?.params.dialog!}
|
||||
url={groupMenuDialogMatch ? groupMenuDialogMatch.pathname : dialogMatch?.pathname || ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -50,7 +50,6 @@ import {
|
|||
error,
|
||||
restrictedPage,
|
||||
signatureNotSupportedWarning,
|
||||
usernameNotSet,
|
||||
} from './auth/notifications';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Routes } from '../routes';
|
||||
|
@ -211,8 +210,7 @@ const UploadPage = () => {
|
|||
const { signature } = tuneParser.parse(await tuneFile!.arrayBuffer()).getTune().details;
|
||||
|
||||
const newData: TunesRecord = {
|
||||
user: currentUser!.id,
|
||||
userProfile: currentUser!.profile!.id,
|
||||
author: currentUser!.id,
|
||||
tuneId: newTuneId!,
|
||||
signature,
|
||||
vehicleName,
|
||||
|
@ -265,13 +263,12 @@ const UploadPage = () => {
|
|||
|
||||
if (existingTune) {
|
||||
// clear old multi files first
|
||||
if (logFiles.length > 0 || toothLogFiles.length > 0) {
|
||||
const tempFormData = new FormData();
|
||||
tempFormData.append('logFiles', '');
|
||||
tempFormData.append('toothLogFiles', '');
|
||||
await updateTune(existingTune.id, tempFormData as unknown as TunesRecord);
|
||||
}
|
||||
const tempFormData = new FormData();
|
||||
tempFormData.append('logFiles', '');
|
||||
tempFormData.append('toothLogFiles', '');
|
||||
await updateTune(existingTune.id, tempFormData as unknown as TunesRecord);
|
||||
|
||||
// another update with new files
|
||||
await updateTune(existingTune.id, formData as unknown as TunesRecord);
|
||||
} else {
|
||||
await createTune(formData as unknown as TunesRecord);
|
||||
|
@ -321,16 +318,24 @@ const UploadPage = () => {
|
|||
}
|
||||
|
||||
const parsed = tuneParser.parse(await file.arrayBuffer());
|
||||
const { signature } = parsed.getTune().details;
|
||||
|
||||
if (!parsed.isValid()) {
|
||||
return {
|
||||
result: false,
|
||||
message: 'Tune file is not valid or not supported!',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchINIFile(parsed.getTune().details.signature);
|
||||
await fetchINIFile(signature);
|
||||
} catch (e) {
|
||||
signatureNotSupportedWarning((e as Error).message);
|
||||
}
|
||||
|
||||
return {
|
||||
result: parsed.isValid(),
|
||||
message: 'Tune file is not valid!',
|
||||
result: true,
|
||||
message: '',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -434,7 +439,7 @@ const UploadPage = () => {
|
|||
|
||||
if (oldTune) {
|
||||
// this is someone elses tune
|
||||
if (oldTune.user !== currentUser?.id) {
|
||||
if (oldTune.author !== currentUser?.id) {
|
||||
navigateToNewTuneId();
|
||||
return;
|
||||
}
|
||||
|
@ -533,13 +538,6 @@ const UploadPage = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if ((user.profile?.username?.length || 0) === 0) {
|
||||
usernameNotSet();
|
||||
navigate(Routes.PROFILE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUserAuthorized(true);
|
||||
prepareData();
|
||||
});
|
||||
|
@ -556,7 +554,7 @@ const UploadPage = () => {
|
|||
<Row style={{ marginTop: 10 }} {...rowProps}>
|
||||
<Col {...colProps}>
|
||||
<Item name="visibility">
|
||||
<Select disabled>
|
||||
<Select>
|
||||
<Select.Option value="public">
|
||||
<Space><GlobalOutlined />Public</Space>
|
||||
</Select.Option>
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
generatePath,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
Pagination,
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||
import { Routes } from '../routes';
|
||||
import { formatTime } from '../utils/time';
|
||||
import useDb from '../hooks/useDb';
|
||||
import { aspirationMapper } from '../utils/tune/mappers';
|
||||
import {
|
||||
TunesRecordFull,
|
||||
UsersRecordFull,
|
||||
} from '../types/dbData';
|
||||
|
||||
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||
|
||||
const Profile = () => {
|
||||
const navigate = useNavigate();
|
||||
const route = useMatch(Routes.USER_ROOT);
|
||||
const { getUserTunes } = useDb();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isTunesLoading, setIsTunesLoading] = useState(false);
|
||||
const [tunesDataSource, setTunesDataSource] = useState<TunesRecordFull[]>([]);
|
||||
const [username, setUsername] = useState();
|
||||
|
||||
const loadData = async () => {
|
||||
setIsTunesLoading(true);
|
||||
try {
|
||||
const { items, totalItems } = await getUserTunes(route?.params.userId!, page, pageSize);
|
||||
setTotal(totalItems);
|
||||
setUsername((items[0].expand.author as UsersRecordFull).username);
|
||||
const mapped = items.map((tune) => ({
|
||||
...tune,
|
||||
key: tune.tuneId,
|
||||
year: tune.year,
|
||||
displacement: `${tune.displacement}l`,
|
||||
aspiration: aspirationMapper[tune.aspiration],
|
||||
published: formatTime(tune.updated),
|
||||
}));
|
||||
setTunesDataSource(mapped as any);
|
||||
} catch (error) {
|
||||
// request cancelled
|
||||
} finally {
|
||||
setIsTunesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="small-container">
|
||||
<Divider>{username ? `${username}'s tunes` : 'No tunes yet'}</Divider>
|
||||
<List
|
||||
dataSource={tunesDataSource}
|
||||
loading={isTunesLoading}
|
||||
renderItem={(tune) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tune.tuneId))} />,
|
||||
]}
|
||||
className={tune.visibility}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<List.Item.Meta
|
||||
title={<>
|
||||
{tune.vehicleName} <Typography.Text code>{tune.signature}</Typography.Text>
|
||||
</>}
|
||||
description={<>
|
||||
{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.aspiration}
|
||||
</>}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text italic>{tune.published}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Pagination
|
||||
style={{ marginTop: 10 }}
|
||||
pageSize={pageSize}
|
||||
current={page}
|
||||
total={total}
|
||||
onChange={(newPage, newPageSize) => {
|
||||
setIsTunesLoading(true);
|
||||
setPage(newPage);
|
||||
setPageSize(newPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
|
@ -19,6 +19,7 @@ import {
|
|||
GithubOutlined,
|
||||
FacebookOutlined,
|
||||
UserAddOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Link,
|
||||
|
@ -40,6 +41,7 @@ import {
|
|||
import {
|
||||
emailRules,
|
||||
requiredRules,
|
||||
usernameRules,
|
||||
} from '../../utils/form';
|
||||
import { buildRedirectUrl } from '../../utils/url';
|
||||
|
||||
|
@ -89,10 +91,10 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const emailSignUp = async ({ email, password }: { email: string, password: string }) => {
|
||||
const emailSignUp = async ({ email, password, username }: { email: string, password: string, username: string }) => {
|
||||
setIsEmailLoading(true);
|
||||
try {
|
||||
const user = await signUp(email, password);
|
||||
const user = await signUp(email, password, username);
|
||||
signUpSuccessful();
|
||||
|
||||
if (!user.verified) {
|
||||
|
@ -242,6 +244,17 @@ const Login = ({ formRole }: { formRole: FormRoles }) => {
|
|||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
{!isLogin && <Item
|
||||
name="username"
|
||||
rules={usernameRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Username"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Item>}
|
||||
<Item
|
||||
name="password"
|
||||
rules={requiredRules}
|
||||
|
|
|
@ -2,7 +2,10 @@ import {
|
|||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
generatePath,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
|
@ -10,10 +13,17 @@ import {
|
|||
Divider,
|
||||
Alert,
|
||||
Space,
|
||||
List,
|
||||
Pagination,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MailOutlined,
|
||||
ArrowRightOutlined,
|
||||
EditOutlined,
|
||||
GlobalOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import validateMessages from './validateMessages';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
@ -26,9 +36,15 @@ import {
|
|||
} from './notifications';
|
||||
import { Routes } from '../../routes';
|
||||
import { usernameRules } from '../../utils/form';
|
||||
import { formatTime } from '../../utils/time';
|
||||
import useDb from '../../hooks/useDb';
|
||||
import { aspirationMapper } from '../../utils/tune/mappers';
|
||||
import { TunesRecordFull } from '../../types/dbData';
|
||||
|
||||
const { Item } = Form;
|
||||
|
||||
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||
|
||||
const Profile = () => {
|
||||
const [formProfile] = Form.useForm();
|
||||
const {
|
||||
|
@ -38,9 +54,19 @@ const Profile = () => {
|
|||
refreshUser,
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { getUserTunes } = useDb();
|
||||
const [isVerificationSent, setIsVerificationSent] = useState(false);
|
||||
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
||||
const [isProfileLoading, setIsProfileLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isTunesLoading, setIsTunesLoading] = useState(false);
|
||||
const [tunesDataSource, setTunesDataSource] = useState<TunesRecordFull[]>([]);
|
||||
|
||||
const goToEdit = (tuneId: string) => navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
|
||||
tuneId,
|
||||
}));
|
||||
|
||||
const resendEmailVerification = async () => {
|
||||
setIsSendingVerification(true);
|
||||
|
@ -69,6 +95,27 @@ const Profile = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
setIsTunesLoading(true);
|
||||
try {
|
||||
const { items, totalItems } = await getUserTunes(currentUser!.id, page, pageSize);
|
||||
setTotal(totalItems);
|
||||
const mapped = items.map((tune) => ({
|
||||
...tune,
|
||||
key: tune.tuneId,
|
||||
year: tune.year,
|
||||
displacement: `${tune.displacement}l`,
|
||||
aspiration: aspirationMapper[tune.aspiration],
|
||||
published: formatTime(tune.updated),
|
||||
}));
|
||||
setTunesDataSource(mapped as any);
|
||||
} catch (error) {
|
||||
// request cancelled
|
||||
} finally {
|
||||
setIsTunesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
restrictedPage();
|
||||
|
@ -83,73 +130,121 @@ const Profile = () => {
|
|||
navigate(Routes.LOGIN);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
{!currentUser?.verified && (<>
|
||||
<Divider>Email verification</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Alert message="Your email address is not verified!" type="error" showIcon />
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%' }}
|
||||
icon={<MailOutlined />}
|
||||
disabled={isVerificationSent}
|
||||
loading={isSendingVerification}
|
||||
onClick={resendEmailVerification}
|
||||
>
|
||||
Resend verification
|
||||
</Button>
|
||||
</Space>
|
||||
</>)}
|
||||
<Divider>Your Profile</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
{(currentUser?.profile?.username?.length || 0) === 0 && <Alert message="Remember to set your username!" type="error" showIcon />}
|
||||
<Form
|
||||
validateMessages={validateMessages}
|
||||
form={formProfile}
|
||||
onFinish={onUpdateProfile}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
value: currentUser?.profile?.username,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
value: currentUser?.email,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Item
|
||||
name="username"
|
||||
rules={usernameRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Username"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Item>
|
||||
<Item name="email">
|
||||
<Input prefix={<MailOutlined />} placeholder="Email" disabled />
|
||||
</Item>
|
||||
<Item>
|
||||
<>
|
||||
<div className="auth-container">
|
||||
{!currentUser?.verified && (<>
|
||||
<Divider>Email verification</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Alert message="Your email address is not verified!" type="error" showIcon />
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
icon={<UserOutlined />}
|
||||
loading={isProfileLoading}
|
||||
icon={<MailOutlined />}
|
||||
disabled={isVerificationSent}
|
||||
loading={isSendingVerification}
|
||||
onClick={resendEmailVerification}
|
||||
>
|
||||
Update
|
||||
Resend verification
|
||||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</div>
|
||||
</Space>
|
||||
</>)}
|
||||
<Divider>Your Profile</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Form
|
||||
validateMessages={validateMessages}
|
||||
form={formProfile}
|
||||
onFinish={onUpdateProfile}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
value: currentUser!.username,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
value: currentUser!.email,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Item
|
||||
name="username"
|
||||
rules={usernameRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Username"
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Item>
|
||||
<Item name="email">
|
||||
<Input prefix={<MailOutlined />} placeholder="Email" disabled />
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
icon={<UserOutlined />}
|
||||
loading={isProfileLoading}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="small-container">
|
||||
<Divider>Your tunes</Divider>
|
||||
<List
|
||||
dataSource={tunesDataSource}
|
||||
loading={isTunesLoading}
|
||||
renderItem={(tune) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
tune.visibility === 'public' ? <GlobalOutlined /> : <EyeOutlined />,
|
||||
<Button icon={<EditOutlined />} onClick={() => goToEdit(tune.tuneId)} />,
|
||||
<Button icon={<ArrowRightOutlined />} onClick={() => navigate(tunePath(tune.tuneId))} />,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<List.Item.Meta
|
||||
title={<>
|
||||
{tune.vehicleName} <Typography.Text code>{tune.signature}</Typography.Text>
|
||||
</>}
|
||||
description={<>
|
||||
{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.aspiration}
|
||||
</>}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text italic>{tune.published}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Pagination
|
||||
style={{ marginTop: 10 }}
|
||||
pageSize={pageSize}
|
||||
current={page}
|
||||
total={total}
|
||||
onChange={(newPage, newPageSize) => {
|
||||
setIsTunesLoading(true);
|
||||
setPage(newPage);
|
||||
setPageSize(newPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -19,12 +19,6 @@ const emailNotVerified = () => notification.warning({
|
|||
...baseOptions,
|
||||
});
|
||||
|
||||
const usernameNotSet = () => notification.warning({
|
||||
message: 'Update your profile',
|
||||
description: 'Your username has to be set before you can upload files!',
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
const signUpSuccessful = () => notification.success({
|
||||
message: 'Sign Up successful',
|
||||
description: 'Welcome on board!',
|
||||
|
@ -156,7 +150,6 @@ const downloading = () => notification.success({
|
|||
export {
|
||||
error,
|
||||
emailNotVerified,
|
||||
usernameNotSet,
|
||||
signUpSuccessful,
|
||||
signUpFailed,
|
||||
logInSuccessful,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import PocketBase, {
|
||||
ClientResponseError,
|
||||
User,
|
||||
Record,
|
||||
} from 'pocketbase';
|
||||
import { fetchEnv } from './utils/env';
|
||||
|
@ -23,16 +22,11 @@ const formatError = (error: any) => {
|
|||
|
||||
const removeFilenameSuffix = (filename: string) => filename.replace(/(.+)(_\w{10})(\.\w+)$/, '$1$3');
|
||||
|
||||
// NOTE: PocketBase doesn't return ISO time, this may change here: https://github.com/pocketbase/pocketbase/issues/376
|
||||
const formatTime = (time: string) => new Date(`${time}Z`).toLocaleString();
|
||||
|
||||
export {
|
||||
API_URL,
|
||||
client,
|
||||
formatError,
|
||||
formatTime,
|
||||
removeFilenameSuffix,
|
||||
ClientResponseError,
|
||||
User,
|
||||
Record,
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ export enum Routes {
|
|||
TUNE_TAB = '/t/:tuneId/:tab',
|
||||
TUNE_TUNE = '/t/:tuneId/tune',
|
||||
TUNE_DIALOG = '/t/:tuneId/tune/:category/:dialog',
|
||||
TUNE_GROUP_MENU_DIALOG = '/t/:tuneId/tune/:category/:groupMenu/:dialog',
|
||||
TUNE_LOGS = '/t/:tuneId/logs',
|
||||
TUNE_LOGS_FILE = '/t/:tuneId/logs/:fileName',
|
||||
TUNE_DIAGNOSE = '/t/:tuneId/diagnose',
|
||||
|
@ -26,6 +27,7 @@ export enum Routes {
|
|||
OAUTH_CALLBACK = '/auth/oauth-callback/:provider',
|
||||
|
||||
ABOUT = '/about',
|
||||
USER_ROOT = '/user/:userId',
|
||||
|
||||
REDIRECT_PAGE_OAUTH_CALLBACK = 'oauth',
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { Record } from '../pocketbase';
|
||||
import {
|
||||
IniFilesRecord,
|
||||
ProfilesRecord,
|
||||
TunesRecord,
|
||||
UsersRecord,
|
||||
} from '../@types/pocketbase-types';
|
||||
|
||||
type Partial<T> = {
|
||||
|
@ -13,6 +13,6 @@ export type TunesRecordPartial = Partial<TunesRecord>;
|
|||
|
||||
export interface TunesRecordFull extends TunesRecord, Record { }
|
||||
|
||||
export interface ProfilesRecordFull extends ProfilesRecord, Record { }
|
||||
export interface UsersRecordFull extends UsersRecord, Record { }
|
||||
|
||||
export interface IniFilesRecordFull extends IniFilesRecord, Record { }
|
||||
|
|
|
@ -45,7 +45,7 @@ class LogValidator implements ParserInterface {
|
|||
private checkMSL() {
|
||||
const lines = this.raw.split('\n');
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (lines[index].startsWith('Time')) {
|
||||
if (lines[index].startsWith('Time') || lines[index].startsWith('RPM')) {
|
||||
this.isMSLLogs = true;
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ class MslLogParser implements ParserInterface {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('Time')) {
|
||||
if (line.startsWith('Time') || line.startsWith('RPM')) {
|
||||
unitsIndex = lineIndex + 1;
|
||||
const fields = line.split('\t');
|
||||
const units = lines[unitsIndex].trim().split('\t');
|
||||
|
|
|
@ -47,8 +47,7 @@ export const colorHsl = (min: number, max: number, value: number): HslType => {
|
|||
// eslint-disable-next-line prefer-template
|
||||
export const round = (value: number, digits: number | string = 0) => +(Math.round(value + `e+${digits}` as any) + `e-${digits}`);
|
||||
|
||||
// TODO: move this or rename to MS
|
||||
export const formatNumber = (value: number, format: string): string => {
|
||||
export const formatNumberMs = (value: number, format: string): string => {
|
||||
if (format === '%d') {
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
@ -60,5 +59,7 @@ export const formatNumber = (value: number, format: string): string => {
|
|||
|
||||
const { digits } = match.groups!;
|
||||
|
||||
return round(value, digits).toFixed(digits as any);
|
||||
return round(value, digits).toFixed(digits as unknown as number);
|
||||
};
|
||||
|
||||
export const formatNumber = (value: number, digits: number): string => round(value, digits).toFixed(digits);
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const formatTime = (time: string) => new Date(time).toLocaleString();
|
|
@ -23,16 +23,16 @@ class TuneParser {
|
|||
const bibliography = xml.getElementsByTagName('bibliography')[0]?.attributes as any;
|
||||
const versionInfo = xml.getElementsByTagName('versionInfo')[0]?.attributes as any;
|
||||
|
||||
if (!xmlPages || !bibliography || !versionInfo) {
|
||||
if (!xmlPages || !versionInfo) {
|
||||
this.isTuneValid = false;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
this.tune.details = {
|
||||
author: bibliography.author.value,
|
||||
tuneComment: `${bibliography.tuneComment.value}`.trim(),
|
||||
writeDate: bibliography.writeDate.value,
|
||||
author: bibliography ? bibliography.author.value : '',
|
||||
tuneComment: bibliography ? `${bibliography.tuneComment.value}`.trim() : '',
|
||||
writeDate: bibliography ? bibliography.writeDate.value : '',
|
||||
fileFormat: versionInfo.fileFormat.value,
|
||||
firmwareInfo: versionInfo.firmwareInfo.value,
|
||||
nPages: Number.parseInt(versionInfo.nPages.value, 2),
|
||||
|
@ -86,7 +86,7 @@ class TuneParser {
|
|||
}
|
||||
|
||||
private isSignatureSupported(): boolean {
|
||||
return this.tune.details.signature.match(/^speeduino \d+$/) === null;
|
||||
return this.tune.details.signature.match(/^(speeduino|rusEFI) .+$/) === null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,8 @@ export const prepareConstDeclarations = (tuneConstants: TuneConstantsType, confi
|
|||
|
||||
// escape string values
|
||||
if (typeof val === 'string') {
|
||||
val = `'${val}'`;
|
||||
// eslint-disable-next-line quotes
|
||||
val = `'${val.replaceAll("'", "\\'")}'`;
|
||||
}
|
||||
|
||||
// some names may have invalid characters, we can fix it or skip it
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { round } from '../numbers';
|
||||
|
||||
export const parseXy = (value: string) => value
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((val) => val.trim())
|
||||
.filter((val) => val !== '')
|
||||
.map(Number);
|
||||
.map((val) => round(Number(val), 2));
|
||||
|
||||
export const parseZ = (value: string) => value
|
||||
.trim()
|
||||
|
|
|
@ -57,6 +57,8 @@ export default ({ mode }) => {
|
|||
}),
|
||||
VitePWA({
|
||||
registerType: null,
|
||||
injectRegister: null,
|
||||
selfDestroying: true,
|
||||
devOptions: { enabled: true },
|
||||
manifest: {
|
||||
name: env.VITE_META_TITLE,
|
||||
|
|
Loading…
Reference in New Issue