Merge branch 'hyper-tuner:master' into master

This commit is contained in:
Josh Stewart 2022-11-02 09:27:20 +11:00 committed by GitHub
commit 7f3023d90f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1628 additions and 1948 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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

2689
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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
}

View File

@ -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 />} />} />

View File

@ -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 (

View File

@ -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) => {

View File

@ -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,
];

View File

@ -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

View File

@ -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}

View File

@ -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>

View File

@ -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);
};
}, []);

View File

@ -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;
}
}

View File

@ -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),
};
};

View File

@ -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),
};
};

View File

@ -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

View File

@ -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}>

View File

@ -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 || ''}
/>
</>
);

View File

@ -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>

116
src/pages/User.tsx Normal file
View File

@ -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;

View File

@ -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}

View File

@ -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>
</>
);
};

View File

@ -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,

View File

@ -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,
};

View File

@ -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',
}

View File

@ -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 { }

View File

@ -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;
}

View File

@ -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');

View File

@ -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);

2
src/utils/time.ts Normal file
View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const formatTime = (time: string) => new Date(time).toLocaleString();

View File

@ -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;
}
}

View File

@ -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

View File

@ -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()

View File

@ -57,6 +57,8 @@ export default ({ mode }) => {
}),
VitePWA({
registerType: null,
injectRegister: null,
selfDestroying: true,
devOptions: { enabled: true },
manifest: {
name: env.VITE_META_TITLE,