This commit is contained in:
Josh Stewart 2022-11-09 11:26:01 +11:00
commit 2f9146b3fa
25 changed files with 1557 additions and 1239 deletions

View File

@ -23,7 +23,7 @@ jobs:
- run: npm install
- run: npm run build
- name: Sentry Release
uses: getsentry/action-release@v1.2.0
uses: getsentry/action-release@v1.2.1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

35
.vscode/settings.json vendored
View File

@ -1,13 +1,26 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [
"hypertuner",
"kbar",
"pocketbase",
"prefs",
"rusefi",
"typegen",
"vite",
"vitejs"
]
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.formatOnSave": true,
"cSpell.words": [
"hypertuner",
"kbar",
"pocketbase",
"prefs",
"rusefi",
"typegen",
"vite",
"vitejs"
],
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[less]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}

View File

@ -38,6 +38,8 @@
### Speeduino
[https://tunes.speeduino.com](https://tunes.speeduino.com)
- Source code: [noisymime/speeduino](https://github.com/noisymime/speeduino)
- Documentation: [wiki.speeduino.com](https://wiki.speeduino.com)

View File

@ -1,28 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/icons/icon.ico" />
<link rel="apple-touch-icon" href="/icons/icon.png" />
<link rel="manifest" href="/manifest.json" />
<link href="css/all.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://apis.google.com" crossorigin>
<meta property="og:title" content="Speeduino Online Tunes (Powered by HyperTuner Cloud)">
<meta name="twitter:image:alt" content="Speeduino Online Tunes">
<meta name="description" content="Share your Speeduino tune and logs" />
<meta property="og:description" content="The best way to share your Speeduino tunes and logs">
<meta property="og:site_name" content="The best way to share your Speeduino tunes and logs">
<meta property="og:image" content="https://tunes.speeduino.com/img/screen2.png">
<meta property="og:url" content="https://tunes.speeduino.com">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content="Speeduino Online Tunes - Share your tunes and logs" />
<title>Speeduino Tune Viewer</title>
</head>
<body style="background-color: #191C1E">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- Vite entrypoint -->
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="utf-8" />
<link rel="icon" href="/icons/icon.ico" />
<link rel="apple-touch-icon" href="/icons/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://apis.google.com" crossorigin>
<meta name="description" content="<%- metaDescription %>">
<title>
<%- metaTitle %>
</title>
<meta property="og:title" content="<%- metaTitle %>">
<meta property="og:site_name" content="<%- metaTitle %>">
<meta property="og:description" content="<%- metaDescription %>">
<meta property="og:image" content="<%- metaImage %>">
<meta property="og:url" content="<%- metaUrl %>">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image:alt" content="<%- metaTitle %>">
<meta name="theme-color" content="<%- metaThemeColor %>">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!-- Vite entrypoint -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1959
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,45 +17,45 @@
"lint": "tsc && eslint --max-warnings=0 src",
"lint:fix": "eslint --fix src",
"analyze": "npm run build && open stats.html",
"typegen": "pocketbase-typegen --json ../pocketbase/pb_schema.json --out src/@types/pocketbase-types.ts"
"typegen": "pocketbase-typegen --json ../cloud-backend/pb_schema.json --out src/@types/pocketbase-types.ts"
},
"dependencies": {
"@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.3",
"@sentry/tracing": "^7.17.3",
"antd": "^4.23.6",
"kbar": "^0.1.0-beta.36",
"@reduxjs/toolkit": "^1.9.0",
"@sentry/react": "^7.18.0",
"@sentry/tracing": "^7.18.0",
"antd": "^4.24.1",
"kbar": "^0.1.0-beta.37",
"lodash.debounce": "^4.0.8",
"mlg-converter": "^0.8.0",
"mlg-converter": "^0.8.1",
"nanoid": "^4.0.0",
"pako": "^2.0.4",
"pako": "^2.1.0",
"pocketbase": "^0.8.0-rc1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^1.4.1",
"react-markdown": "^8.0.3",
"react-perfect-scrollbar": "^1.5.8",
"react-redux": "^8.0.4",
"react-router-dom": "^6.4.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.4.3",
"uplot": "^1.6.22",
"uplot-react": "^1.1.1",
"vite": "^3.2.2"
"vite": "^3.2.3"
},
"devDependencies": {
"@hyper-tuner/eslint-config": "git+https://github.com/hyper-tuner/eslint-config.git",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.11.9",
"@types/pako": "^2.0.0",
"@types/react": "^18.0.24",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.26.0",
"eslint": "^8.27.0",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
@ -64,11 +64,11 @@
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",
"less": "^4.1.3",
"pocketbase-typegen": "^1.0.11",
"pocketbase-typegen": "^1.0.13",
"prettier": "^2.7.1",
"rollup-plugin-visualizer": "^5.8.3",
"typescript": "^4.8.4",
"vite-plugin-html": "^3.2.0",
"vite-plugin-pwa": "^0.13.1"
"vite-plugin-pwa": "^0.13.3"
}
}

View File

@ -7,57 +7,77 @@ export type RecordIdString = string
export type UserIdString = string
export type BaseRecord = {
id: RecordIdString
created: IsoDateString
updated: IsoDateString
collectionId: string
collectionName: string
id: RecordIdString
created: IsoDateString
updated: IsoDateString
collectionId: string
collectionName: string
expand?: { [key: string]: any }
}
export enum Collections {
IniFiles = 'iniFiles',
Tunes = 'tunes',
Users = 'users',
IniFiles = 'iniFiles',
Stargazers = 'stargazers',
Tunes = 'tunes',
Users = 'users',
}
export type IniFilesRecord = {
signature: string
file: string
ecosystem: 'speeduino' | 'rusefi'
signature: string
file: string
ecosystem: 'speeduino' | 'rusefi'
}
export type IniFilesResponse = IniFilesRecord & BaseRecord
export type StargazersRecord = {
user: RecordIdString
tune: RecordIdString
}
export type StargazersResponse = StargazersRecord & BaseRecord
export type TunesRecord = {
author: RecordIdString
tuneId: string
signature: string
vehicleName: string
engineMake: string
engineCode: string
displacement: number
cylindersCount: number
aspiration: 'na' | 'turbocharged' | 'supercharged'
compression?: number
fuel?: string
ignition?: string
injectorsSize?: number
year?: number
hp?: number
stockHp?: number
readme: string
textSearch: string
visibility: 'public' | 'unlisted'
tuneFile: string
customIniFile?: string
logFiles?: string[]
toothLogFiles?: string[]
author: RecordIdString
tuneId: string
signature: string
stars?: number
vehicleName: string
engineMake: string
engineCode: string
displacement: number
cylindersCount: number
aspiration: 'na' | 'turbocharged' | 'supercharged'
compression?: number
fuel?: string
ignition?: string
injectorsSize?: number
year?: number
hp?: number
stockHp?: number
readme: string
textSearch: string
visibility: 'public' | 'unlisted'
tuneFile: string
customIniFile?: string
logFiles?: string[]
toothLogFiles?: string[]
}
export type TunesResponse = TunesRecord & BaseRecord
export type UsersRecord = {
avatar?: string
avatar?: string
username: string
email: string
verified: boolean
}
export type UsersResponse = UsersRecord & BaseRecord
export type CollectionRecords = {
iniFiles: IniFilesRecord
tunes: TunesRecord
users: UsersRecord
iniFiles: IniFilesRecord
stargazers: StargazersRecord
tunes: TunesRecord
users: UsersRecord
}

View File

@ -34,12 +34,12 @@ import Info from './pages/Info';
import Hub from './pages/Hub';
import { FormRoles } from './pages/auth/Login';
import useServerStorage from './hooks/useServerStorage';
import { TunesRecordFull } from './types/dbData';
import TuneParser from './utils/tune/TuneParser';
import standardDialogs from './data/standardDialogs';
import help from './data/help';
import {
iniLoadingError,
tuneNotFound,
tuneParsingError,
} from './pages/auth/notifications';
import { divider } from './data/constants';
@ -51,6 +51,7 @@ import {
import 'uplot/dist/uPlot.min.css';
import 'react-perfect-scrollbar/dist/css/styles.css';
import './css/App.less';
import { TunesResponse } from './@types/pocketbase-types';
const Tune = lazy(() => import('./pages/Tune'));
const Logs = lazy(() => import('./pages/Logs'));
@ -82,7 +83,7 @@ const App = ({ ui, tuneData }: { ui: UIState, tuneData: TuneDataState }) => {
const tuneId = tunePathMatch?.params.tuneId;
const { fetchINIFile, fetchTuneFile } = useServerStorage();
const loadTune = async (data: TunesRecordFull | null) => {
const loadTune = async (data: TunesResponse | null) => {
if (data === null) {
store.dispatch({ type: 'config/load', payload: null });
store.dispatch({ type: 'tune/load', payload: null });
@ -120,7 +121,9 @@ const App = ({ ui, tuneData }: { ui: UIState, tuneData: TuneDataState }) => {
store.dispatch({ type: 'tune/load', payload: tune });
} catch (error) {
iniLoadingError((error as Error));
navigate(Routes.HUB);
navigate(generatePath(Routes.TUNE_ROOT, {
tuneId: tuneId!,
}));
}
};
@ -149,7 +152,7 @@ const App = ({ ui, tuneData }: { ui: UIState, tuneData: TuneDataState }) => {
getTune(tuneId).then(async (tune) => {
if (!tune) {
console.warn('Tune not found');
tuneNotFound();
navigate(Routes.HUB);
return;
}

View File

@ -0,0 +1,96 @@
import {
useEffect,
useState,
} from 'react';
import {
Badge,
Button,
Space,
Tooltip,
} from 'antd';
import {
StarOutlined,
StarFilled,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { Colors } from '../utils/colors';
import { TuneDataState } from '../types/state';
import useDb from '../hooks/useDb';
import { useAuth } from '../contexts/AuthContext';
import { Routes } from '../routes';
const StarButton = ({ tuneData }: { tuneData: TuneDataState }) => {
const navigate = useNavigate();
const { currentUserToken } = useAuth();
const { toggleStar, isStarredByMe } = useDb();
const [currentStars, setCurrentStars] = useState(tuneData.stars);
const [isCurrentlyStarred, setIsCurrentlyStarred] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const toggleStarClick = async () => {
if (!currentUserToken) {
navigate(Routes.LOGIN);
return;
}
try {
setIsLoading(true);
const { stars, isStarred } = await toggleStar(currentUserToken, tuneData.id);
setCurrentStars(stars);
setIsCurrentlyStarred(isStarred);
setIsLoading(false);
} catch (error) {
setIsLoading(false);
throw error;
}
};
useEffect(() => {
if (!currentUserToken) {
setIsLoading(false);
return;
}
setIsLoading(true);
isStarredByMe(currentUserToken, tuneData.id).then((isStarred) => {
setIsCurrentlyStarred(isStarred);
setIsLoading(false);
}).catch((error) => {
setIsLoading(false);
throw error;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUserToken, tuneData.id]);
return (
<div style={{ textAlign: 'right' }}>
<Tooltip
title="You must be signed in to star a tune"
placement="bottom"
trigger={currentUserToken ? 'none' : 'hover'}
>
<Button
icon={isCurrentlyStarred ? <StarFilled style={{ color: Colors.YELLOW }} /> : <StarOutlined />}
onClick={toggleStarClick}
loading={isLoading}
>
<Space style={{ marginLeft: 10 }}>
<div>{isCurrentlyStarred ? 'Starred' : 'Star'}</div>
<Badge
count={currentStars}
style={{ backgroundColor: Colors.TEXT, marginTop: -4 }}
showZero
/>
</Space>
</Button>
</Tooltip>
</div>
);
};
export default StarButton;

View File

@ -22,7 +22,6 @@ import {
Col,
Tooltip,
Grid,
Menu,
Dropdown,
Typography,
Radio,
@ -304,9 +303,7 @@ const TopBar = ({
</Button>
</Link>
{tuneData?.tuneId && <Dropdown
overlay={
<Menu triggerSubMenuAction="click" items={downloadItems} />
}
menu={{ items: downloadItems }}
placement="bottom"
trigger={['click']}
>
@ -315,7 +312,7 @@ const TopBar = ({
</Button>
</Dropdown>}
<Dropdown
overlay={<Menu items={userMenuItems} />}
menu={{ items: userMenuItems }}
placement="bottomRight"
trigger={['click']}
>

View File

@ -11,9 +11,11 @@ import {
formatError,
} from '../pocketbase';
import { buildRedirectUrl } from '../utils/url';
import { Collections } from '../@types/pocketbase-types';
import {
Collections,
UsersResponse,
} 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 +41,11 @@ export enum OAuthProviders {
};
interface AuthValue {
currentUser: UsersRecordFull | null,
signUp: (email: string, password: string, username: string) => Promise<UsersRecordFull>,
login: (email: string, password: string) => Promise<UsersRecordFull>,
refreshUser: () => Promise<UsersRecordFull | null>,
currentUser: UsersResponse | null,
currentUserToken: string | null,
signUp: (email: string, password: string, username: string) => Promise<UsersResponse>,
login: (email: string, password: string) => Promise<UsersResponse>,
refreshUser: () => Promise<UsersResponse | null>,
sendEmailVerification: () => Promise<void>,
confirmEmailVerification: (token: string) => Promise<void>,
confirmResetPassword: (token: string, password: string) => Promise<void>,
@ -61,13 +64,15 @@ const users = client.collection(Collections.Users);
const AuthProvider = (props: { children: ReactNode }) => {
const { children } = props;
const [currentUser, setCurrentUser] = useState<UsersRecordFull | null>(null);
const [currentUser, setCurrentUser] = useState<UsersResponse | null>(null);
const [currentUserToken, setCurrentUserToken] = useState<string | null>(null);
const value = useMemo(() => ({
currentUser,
currentUserToken,
signUp: async (email: string, password: string, username: string) => {
try {
const user = await users.create({
const user = await users.create<UsersResponse>({
email,
password,
passwordConfirm: password,
@ -83,7 +88,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
},
login: async (email: string, password: string) => {
try {
const authResponse = await users.authWithPassword(email, password);
const authResponse = await users.authWithPassword<UsersResponse>(email, password);
return Promise.resolve(authResponse.record);
} catch (error) {
return Promise.reject(new Error(formatError(error)));
@ -91,7 +96,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
},
refreshUser: async () => {
try {
const authResponse = await users.authRefresh();
const authResponse = await users.authRefresh<UsersResponse>();
return Promise.resolve(authResponse.record);
} catch (error) {
client.authStore.clear();
@ -159,13 +164,15 @@ const AuthProvider = (props: { children: ReactNode }) => {
return Promise.reject(new Error(formatError(error)));
}
},
}), [currentUser]);
}), [currentUser, currentUserToken]);
useEffect(() => {
setCurrentUser(client.authStore.model as UsersRecordFull | null);
setCurrentUser(client.authStore.model as UsersResponse | null);
setCurrentUserToken(client.authStore.token);
const storeUnsubscribe = client.authStore.onChange((_token, model) => {
setCurrentUser(model as UsersRecordFull | null);
const storeUnsubscribe = client.authStore.onChange((token, model) => {
setCurrentUser(model as UsersResponse | null);
setCurrentUserToken(token);
});
return () => {

View File

@ -1,23 +1,32 @@
@keyframes wiggle {
0%, 7% {
0%,
7% {
transform: rotateZ(0);
}
15% {
transform: rotateZ(-15deg);
}
20% {
transform: rotateZ(10deg);
}
25% {
transform: rotateZ(-10deg);
}
30% {
transform: rotateZ(6deg);
}
35% {
transform: rotateZ(-4deg);
}
40%, 100% {
40%,
100% {
transform: rotateZ(0);
}
}

View File

@ -1,10 +1,6 @@
// ant design
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-delete,
.ant-upload-list-picture-card
.ant-upload-list-item-actions
.anticon-eye {
.ant-upload-list-picture-card .ant-upload-list-item-actions .anticon-delete,
.ant-upload-list-picture-card .ant-upload-list-item-actions .anticon-eye {
color: @text;
}
@ -17,7 +13,7 @@
--shadow: @shadow-2;
}
reach-portal > div {
reach-portal>div {
z-index: 1;
backdrop-filter: blur(3px);
}

View File

@ -3,23 +3,38 @@ import {
client,
formatError,
ClientResponseError,
API_URL,
} from '../pocketbase';
import {
IniFilesRecordFull,
TunesRecordFull,
TunesRecordPartial,
} from '../types/dbData';
import { databaseGenericError } from '../pages/auth/notifications';
import {
Collections,
IniFilesResponse,
TunesRecord,
TunesResponse,
} from '../@types/pocketbase-types';
type Partial<T> = {
[A in keyof T]?: T[A];
};
export type TunesRecordPartial = Partial<TunesRecord>;
type TunesResponseList = {
items: TunesResponse[];
totalItems: number;
}
const tunesCollection = client.collection(Collections.Tunes);
const iniFilesCollection = client.collection(Collections.IniFiles);
const customEndpoint = `${API_URL}/api/custom`;
const headers = (token: string) => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
});
const useDb = () => {
const updateTune = async (id: string, data: TunesRecordPartial) => {
const updateTune = async (id: string, data: TunesRecordPartial): Promise<void> => {
try {
await tunesCollection.update(id, data);
return Promise.resolve();
@ -31,11 +46,11 @@ const useDb = () => {
}
};
const createTune = async (data: TunesRecord) => {
const createTune = async (data: TunesRecord): Promise<TunesResponse> => {
try {
const record = await tunesCollection.create(data);
const record = await tunesCollection.create<TunesResponse>(data);
return Promise.resolve(record as TunesRecordFull);
return Promise.resolve(record);
} catch (error) {
Sentry.captureException(error);
databaseGenericError(new Error(formatError(error)));
@ -44,54 +59,41 @@ const useDb = () => {
}
};
const getTune = async (tuneId: string) => {
try {
const tune = await tunesCollection.getFirstListItem(
`tuneId = "${tuneId}"`,
{
expand: 'author',
},
);
const getTune = async (tuneId: string): Promise<TunesResponse | null> => {
const response = await fetch(`${customEndpoint}/tunes/byTuneId/${tuneId}`);
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)));
return Promise.reject(error);
if (response.ok) {
return response.json();
}
if (response.status === 404) {
return Promise.resolve(null);
}
Sentry.captureException(response);
databaseGenericError(new Error(response.statusText));
return Promise.reject(response.status);
};
const getIni = async (signature: string) => {
try {
const ini = await iniFilesCollection.getFirstListItem(`signature = "${signature}"`);
const getIni = async (signature: string): Promise<IniFilesResponse | null> => {
const response = await fetch(`${customEndpoint}/iniFiles/bySignature/${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);
if (response.ok) {
return response.json();
}
if (response.status === 404) {
return Promise.resolve(null);
}
Sentry.captureException(response);
databaseGenericError(new Error(response.statusText));
return Promise.reject(response.status);
};
const searchTunes = async (search: string, page: number, perPage: number) => {
const searchTunes = async (search: string, page: number, perPage: number): Promise<TunesResponseList> => {
const phrases = search.length > 0 ? search.replace(/ +(?= )/g, '').split(' ') : [];
const filter = phrases
.filter((phrase) => phrase.length > 1)
@ -99,14 +101,14 @@ const useDb = () => {
.join(' && ');
try {
const list = await tunesCollection.getList(page, perPage, {
sort: '-updated',
const list = await tunesCollection.getList<TunesResponse>(page, perPage, {
sort: '-stars,-updated',
filter,
expand: 'author',
});
return Promise.resolve({
items: list.items as TunesRecordFull[],
items: list.items,
totalItems: list.totalItems,
});
} catch (error) {
@ -121,16 +123,16 @@ const useDb = () => {
}
};
const getUserTunes = async (userId: string, page: number, perPage: number) => {
const getUserTunes = async (userId: string, page: number, perPage: number): Promise<TunesResponseList> => {
try {
const list = await tunesCollection.getList(page, perPage, {
const list = await tunesCollection.getList<TunesResponse>(page, perPage, {
sort: '-updated',
filter: `author = "${userId}"`,
expand: 'author',
});
return Promise.resolve({
items: list.items as TunesRecordFull[],
items: list.items,
totalItems: list.totalItems,
});
} catch (error) {
@ -145,13 +147,13 @@ const useDb = () => {
}
};
const autocomplete = async (attribute: string, search: string) => {
const autocomplete = async (attribute: string, search: string): Promise<TunesResponse[]> => {
try {
const items = await tunesCollection.getFullList(10, {
const items = await tunesCollection.getFullList<TunesResponse>(10, {
filter: `${attribute} ~ "${search}"`,
});
return Promise.resolve(items as TunesRecordFull[]);
return Promise.resolve(items);
} catch (error) {
if ((error as ClientResponseError).isAbort) {
return Promise.reject(new Error('Cancelled'));
@ -164,14 +166,60 @@ const useDb = () => {
}
};
const toggleStar = async (currentUserToken: string, tune: string): Promise<{ stars: number, isStarred: boolean }> => {
const response = await fetch(`${customEndpoint}/stargazers/toggleStar`, {
method: 'POST',
headers: headers(currentUserToken),
body: JSON.stringify({ tune }),
});
if (response.ok) {
const { stars, isStarred } = await response.json();
return Promise.resolve({ stars, isStarred });
}
if (response.status === 404) {
return Promise.resolve({ stars: 0, isStarred: false });
}
Sentry.captureException(response);
databaseGenericError(new Error(response.statusText));
return Promise.reject(response.status);
};
const isStarredByMe = async (currentUserToken: string, tune: string): Promise<boolean> => {
const response = await fetch(`${customEndpoint}/stargazers/starredByMe/${tune}`, {
headers: headers(currentUserToken),
});
if (response.ok) {
const { isStarred } = await response.json();
return Promise.resolve(isStarred);
}
if (response.status === 404) {
return Promise.resolve(false);
}
Sentry.captureException(response);
databaseGenericError(new Error(response.statusText));
return Promise.reject(response.status);
};
return {
updateTune: (tuneId: string, data: TunesRecordPartial): Promise<void> => updateTune(tuneId, data),
createTune: (data: TunesRecord): Promise<TunesRecordFull> => createTune(data),
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),
updateTune,
createTune,
getTune,
getIni,
searchTunes,
getUserTunes,
autocomplete,
toggleStar,
isStarredByMe,
};
};

View File

@ -19,12 +19,12 @@ import {
Divider,
Typography,
Badge,
Grid,
} from 'antd';
import {
FileTextOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import useBreakpoint from 'antd/lib/grid/hooks/useBreakpoint';
import { connect } from 'react-redux';
import PerfectScrollbar from 'react-perfect-scrollbar';
import Pako from 'pako';
@ -54,7 +54,6 @@ import {
} from '../components/Tune/SideBar';
const { Content } = Layout;
const { Step } = Steps;
const edgeUnknown = 'Unknown';
const badgeStyle = { backgroundColor: Colors.TEXT };
@ -76,7 +75,7 @@ const Diagnose = ({
loadedToothLogs: ToothLogsState;
tuneData: TuneDataState | null;
}) => {
const { lg } = useBreakpoint();
const { lg } = Grid.useBreakpoint();
const { Sider } = Layout;
const [progress, setProgress] = useState(0);
const [fileSize, setFileSize] = useState<string>();
@ -277,25 +276,29 @@ const Diagnose = ({
className="logs-progress"
/>
<Divider />
<Steps current={step} direction={lg ? 'horizontal' : 'vertical'}>
<Step
title="Downloading"
subTitle={fileSize}
description={
fetchError ? fetchError!.message : <Space>
<GlobalOutlined />{edgeLocation}
</Space>
}
/>
<Step
title="Decoding"
description="Parsing CSV"
/>
<Step
title="Rendering"
description="Putting pixels on your screen"
/>
</Steps>
<Steps
current={step}
direction={lg ? 'horizontal' : 'vertical'}
items={[
{
title: 'Downloading',
subTitle: fileSize,
description: (
fetchError ? fetchError!.message : <Space>
<GlobalOutlined />{edgeLocation}
</Space>
),
},
{
title: 'Decoding',
description: 'Parsing CSV',
},
{
title: 'Rendering',
description: 'Putting pixels on your screen',
},
]}
/>
</Space>
}
</div>

View File

@ -36,12 +36,12 @@ import {
isClipboardSupported,
} from '../utils/clipboard';
import { isEscape } from '../utils/keyboard/shortcuts';
import {
TunesRecordFull,
UsersRecordFull,
} from '../types/dbData';
import { formatTime } from '../utils/time';
import { useAuth } from '../contexts/AuthContext';
import {
TunesResponse,
UsersResponse,
} from '../@types/pocketbase-types';
const { useBreakpoint } = Grid;
const { Text, Title } = Typography;
@ -52,7 +52,7 @@ const Hub = () => {
const { xs } = useBreakpoint();
const { searchTunes } = useDb();
const navigate = useNavigate();
const [dataSource, setDataSource] = useState<TunesRecordFull[]>([]);
const [dataSource, setDataSource] = useState<TunesResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
@ -73,11 +73,10 @@ const Hub = () => {
...tune,
key: tune.tuneId,
year: tune.year,
authorUsername: (tune.expand.author as unknown as UsersRecordFull).username,
authorUsername: (tune.expand!.author as unknown as UsersResponse).username,
displacement: `${tune.displacement}l`,
aspiration: aspirationMapper[tune.aspiration],
published: formatTime(tune.updated),
stars: 0,
updated: formatTime(tune.updated),
}));
setDataSource(mapped as any);
} catch (error) {
@ -114,14 +113,14 @@ const Hub = () => {
const columns: ColumnsType<any> = [
{
title: 'Tunes',
render: (tune: TunesRecordFull) => (
render: (tune: TunesResponse) => (
<>
<Title level={5}>{tune.vehicleName}</Title>
<Space direction="vertical">
<Text type="secondary">
<Link to={generatePath(Routes.USER_ROOT, { userId: tune.author })}>
{tune.authorUsername}
</Link>, {tune.published}
{(tune as any).authorUsername}
</Link>, {tune.updated}
</Text>
<Text>{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.cylindersCount} cylinders, {tune.aspiration}</Text>
<Text code>{tune.signature}</Text>
@ -171,7 +170,7 @@ const Hub = () => {
dataIndex: 'authorUsername',
key: 'authorUsername',
responsive: ['sm'],
render: (userName: string, record: TunesRecordFull) => (
render: (userName: string, record: TunesResponse) => (
<Link to={generatePath(Routes.USER_ROOT, { userId: record.author })}>
{userName}
</Link>
@ -185,8 +184,8 @@ const Hub = () => {
},
{
title: 'Published',
dataIndex: 'published',
key: 'published',
dataIndex: 'updated',
key: 'updated',
responsive: ['sm'],
},
{
@ -198,7 +197,7 @@ const Hub = () => {
{
dataIndex: 'tuneId',
fixed: 'right',
render: (tuneId: string, record: TunesRecordFull) => {
render: (tuneId: string, record: TunesResponse) => {
const isOwner = currentUser?.id === record.author;
const size = isOwner ? 'small' : 'middle';

View File

@ -22,7 +22,8 @@ import Loader from '../components/Loader';
import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext';
import { formatTime } from '../utils/time';
import { UsersRecordFull } from '../types/dbData';
import { UsersResponse } from '../@types/pocketbase-types';
import StarButton from '../components/StarButton';
const { Item } = Form;
const rowProps = { gutter: 10 };
@ -66,12 +67,13 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
return (
<div className="small-container">
<StarButton tuneData={tuneData} />
<Divider>Details</Divider>
<Form>
<Row {...rowProps}>
<Col {...colProps}>
<Item>
<Input value={(tuneData.expand.author as unknown as UsersRecordFull).username} addonBefore="Author" />
<Input value={(tuneData.expand!.author as unknown as UsersResponse).username} addonBefore="Author" />
</Item>
</Col>
<Col {...colProps}>

View File

@ -21,14 +21,14 @@ import {
Divider,
Badge,
Typography,
Grid,
} from 'antd';
import {
FileTextOutlined,
EditOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { CheckboxValueType } from 'antd/lib/checkbox/Group';
import useBreakpoint from 'antd/lib/grid/hooks/useBreakpoint';
import { CheckboxValueType } from 'antd/es/checkbox/Group';
import { connect } from 'react-redux';
import { Result as ParserResult } from 'mlg-converter/dist/types';
import PerfectScrollbar from 'react-perfect-scrollbar';
@ -70,7 +70,6 @@ import {
} from '../components/Tune/SideBar';
const { Content } = Layout;
const { Step } = Steps;
const edgeUnknown = 'Unknown';
const minCanvasHeightInner = 500;
const badgeStyle = { backgroundColor: Colors.TEXT };
@ -93,7 +92,7 @@ const Logs = ({
loadedLogs: LogsState;
tuneData: TuneDataState | null;
}) => {
const { lg } = useBreakpoint();
const { lg } = Grid.useBreakpoint();
const { Sider } = Layout;
const [progress, setProgress] = useState(0);
const [fileSize, setFileSize] = useState<string>();
@ -384,29 +383,33 @@ const Logs = ({
className="logs-progress"
/>
<Divider />
<Steps current={step} direction={lg ? 'horizontal' : 'vertical'}>
<Step
title="Downloading"
subTitle={fileSize}
description={
fetchError ? fetchError!.message : <Space>
<GlobalOutlined />{edgeLocation}
</Space>
}
status={fetchError && 'error'}
/>
<Step
title="Decoding"
description={parseError ? parseError!.message : 'Reading ones and zeros'}
subTitle={parseElapsed}
status={parseError && 'error'}
/>
<Step
title="Rendering"
description="Putting pixels on your screen"
subTitle={samplesCount && `${samplesCount} samples`}
/>
</Steps>
<Steps
current={step}
direction={lg ? 'horizontal' : 'vertical'}
items={[
{
title: 'Downloading',
subTitle: fileSize,
description: (
fetchError ? fetchError!.message : <Space>
<GlobalOutlined />{edgeLocation}
</Space>
),
status: fetchError && 'error',
},
{
title: 'Decoding',
description: parseError ? parseError!.message : 'Reading ones and zeros',
subTitle: parseElapsed,
status: parseError && 'error',
},
{
title: 'Rendering',
description: 'Putting pixels on your screen',
subTitle: samplesCount && `${samplesCount} samples`,
},
]}
/>
</Space>
}
</div>

View File

@ -56,7 +56,7 @@ import { Routes } from '../routes';
import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogValidator from '../utils/logs/LogValidator';
import useDb from '../hooks/useDb';
import useDb, { TunesRecordPartial } from '../hooks/useDb';
import useServerStorage from '../hooks/useServerStorage';
import { buildFullUrl } from '../utils/url';
import Loader from '../components/Loader';
@ -66,11 +66,10 @@ import {
} from '../utils/form';
import { aspirationMapper } from '../utils/tune/mappers';
import { copyToClipboard } from '../utils/clipboard';
import { TunesRecord } from '../@types/pocketbase-types';
import {
TunesRecordFull,
TunesRecordPartial,
} from '../types/dbData';
TunesRecord,
TunesResponse,
} from '../@types/pocketbase-types';
import { removeFilenameSuffix } from '../pocketbase';
const { Item, useForm } = Form;
@ -124,7 +123,7 @@ const UploadPage = () => {
const [isPublished, setIsPublished] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [readme, setReadme] = useState(defaultReadme);
const [existingTune, setExistingTune] = useState<TunesRecordFull>();
const [existingTune, setExistingTune] = useState<TunesResponse>();
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
@ -136,6 +135,8 @@ const UploadPage = () => {
const [logFiles, setLogFiles] = useState<File[]>([]);
const [toothLogFiles, setToothLogFiles] = useState<File[]>([]);
const [customIniRequired, setCustomIniRequired] = useState(false);
const shareSupported = 'share' in navigator;
const { currentUser, refreshUser } = useAuth();
const navigate = useNavigate();
@ -151,7 +152,7 @@ const UploadPage = () => {
}
const options = (await autocomplete(attribute, search))
.map((record) => record[attribute]);
.map((record) => (record as any)[attribute]);
// TODO: order by occurrence (more common - higher in the list)
const unique = [...new Set(options)].map((value) => ({ value }));
@ -242,7 +243,7 @@ const UploadPage = () => {
fuel,
ignition,
year,
].filter((field) => field !== null && `${field}`.length > 1)
].filter((field) => field !== null && `${field}`.length > 1 && field !== 'null')
.join(' ')
.replace(/[^\w.\-\d ]/g, ''),
};
@ -262,7 +263,7 @@ const UploadPage = () => {
});
if (existingTune) {
// clear old multi files first
// always clear old multi files first since there is no other way to handle this
const tempFormData = new FormData();
tempFormData.append('logFiles', '');
tempFormData.append('toothLogFiles', '');
@ -330,9 +331,17 @@ const UploadPage = () => {
try {
await fetchINIFile(signature);
} catch (e) {
setCustomIniRequired(true);
signatureNotSupportedWarning((e as Error).message);
return {
result: true,
message: '',
};
}
setCustomIniRequired(false);
return {
result: true,
message: '',
@ -410,6 +419,10 @@ const UploadPage = () => {
validationMessage = (e as Error).message;
}
if (valid) {
setCustomIniRequired(false);
}
return {
result: valid,
message: validationMessage,
@ -422,11 +435,11 @@ const UploadPage = () => {
};
const removeLogFile = async (file: UploadFile) => {
setLogFiles((prev) => prev.filter((f) => f.name !== file.name));
setLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeToothLogFile = async (file: UploadFile) => {
setToothLogFiles((prev) => prev.filter((f) => f.name !== file.name));
setToothLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeCustomIniFile = async (file: UploadFile) => {
@ -453,7 +466,7 @@ const UploadPage = () => {
setTuneFile(await fetchFile(oldTune.id, oldTune.tuneFile));
setDefaultTuneFileList([{
uid: oldTune.tuneFile,
name: oldTune.tuneFile,
name: removeFilenameSuffix(oldTune.tuneFile),
status: 'done',
}]);
}
@ -462,7 +475,7 @@ const UploadPage = () => {
setCustomIniFile(await fetchFile(oldTune.id, oldTune.customIniFile));
setDefaultCustomIniFileList([{
uid: oldTune.customIniFile,
name: oldTune.customIniFile,
name: removeFilenameSuffix(oldTune.customIniFile),
status: 'done',
}]);
}
@ -472,7 +485,7 @@ const UploadPage = () => {
tempLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultLogFilesList((prev) => [...prev, {
uid: fileName,
name: fileName,
name: removeFilenameSuffix(fileName),
status: 'done',
}]);
});
@ -483,7 +496,7 @@ const UploadPage = () => {
tempToothLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultToothLogFilesList((prev) => [...prev, {
uid: fileName,
name: fileName,
name: removeFilenameSuffix(fileName),
status: 'done',
}]);
});
@ -550,6 +563,14 @@ const UploadPage = () => {
</Space>
);
const publishButtonText = () => {
if (customIniRequired) {
return 'Custom INI file required!';
}
return isEditMode ? 'Update' : 'Publish';
};
const publishButton = (
<Row style={{ marginTop: 10 }} {...rowProps}>
<Col {...colProps}>
@ -572,8 +593,9 @@ const UploadPage = () => {
loading={isLoading}
htmlType="submit"
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
disabled={customIniRequired}
>
{isEditMode ? 'Update' : 'Publish'}
{publishButtonText()}
</Button>
</Item>
</Col>

View File

@ -21,9 +21,9 @@ import { formatTime } from '../utils/time';
import useDb from '../hooks/useDb';
import { aspirationMapper } from '../utils/tune/mappers';
import {
TunesRecordFull,
UsersRecordFull,
} from '../types/dbData';
TunesResponse,
UsersResponse,
} from '../@types/pocketbase-types';
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
@ -35,22 +35,22 @@ const Profile = () => {
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 [tunesDataSource, setTunesDataSource] = useState<TunesResponse[]>([]);
const [username, setUsername] = useState<string>();
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);
setUsername((items[0]!.expand!.author as UsersResponse).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),
updated: formatTime(tune.updated),
}));
setTunesDataSource(mapped as any);
} catch (error) {
@ -88,7 +88,7 @@ const Profile = () => {
</>}
/>
<div>
<Typography.Text italic>{tune.published}</Typography.Text>
<Typography.Text italic>{tune.updated}</Typography.Text>
</div>
</Space>
</List.Item>

View File

@ -39,7 +39,7 @@ 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';
import { TunesResponse } from '../../@types/pocketbase-types';
const { Item } = Form;
@ -62,7 +62,7 @@ const Profile = () => {
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [isTunesLoading, setIsTunesLoading] = useState(false);
const [tunesDataSource, setTunesDataSource] = useState<TunesRecordFull[]>([]);
const [tunesDataSource, setTunesDataSource] = useState<TunesResponse[]>([]);
const goToEdit = (tuneId: string) => navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId,
@ -106,7 +106,7 @@ const Profile = () => {
year: tune.year,
displacement: `${tune.displacement}l`,
aspiration: aspirationMapper[tune.aspiration],
published: formatTime(tune.updated),
updated: formatTime(tune.updated),
}));
setTunesDataSource(mapped as any);
} catch (error) {
@ -132,7 +132,7 @@ const Profile = () => {
});
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return (
@ -163,11 +163,11 @@ const Profile = () => {
fields={[
{
name: 'username',
value: currentUser!.username,
value: currentUser?.username,
},
{
name: 'email',
value: currentUser!.email,
value: currentUser?.email,
},
]}
>
@ -222,7 +222,7 @@ const Profile = () => {
</>}
/>
<div>
<Typography.Text italic>{tune.published}</Typography.Text>
<Typography.Text italic>{tune.updated}</Typography.Text>
</div>
</Space>
</List.Item>

View File

@ -42,7 +42,7 @@ const logInFailed = (err: Error) => notification.error({
...baseOptions,
});
const restrictedPage = () => notification.error({
const restrictedPage = () => notification.warning({
message: 'Restricted page',
description: 'You have to be logged in to access this page!',
...baseOptions,
@ -125,6 +125,11 @@ const iniLoadingError = (err: Error) => notification.error({
...baseOptions,
});
const tuneNotFound = () => notification.warning({
message: 'Tune not found',
...baseOptions,
});
const tuneParsingError = () => notification.error({
message: 'Tune file is not valid',
...baseOptions,
@ -169,6 +174,7 @@ export {
databaseGenericError,
copiedToClipboard,
iniLoadingError,
tuneNotFound,
tuneParsingError,
signatureNotSupportedWarning,
downloading,

View File

@ -1,7 +1,4 @@
import PocketBase, {
ClientResponseError,
Record,
} from 'pocketbase';
import PocketBase, { ClientResponseError } from 'pocketbase';
import { fetchEnv } from './utils/env';
const API_URL = fetchEnv('VITE_POCKETBASE_API_URL');
@ -28,5 +25,4 @@ export {
formatError,
removeFilenameSuffix,
ClientResponseError,
Record,
};

View File

@ -1,18 +0,0 @@
import { Record } from '../pocketbase';
import {
IniFilesRecord,
TunesRecord,
UsersRecord,
} from '../@types/pocketbase-types';
type Partial<T> = {
[A in keyof T]?: T[A];
};
export type TunesRecordPartial = Partial<TunesRecord>;
export interface TunesRecordFull extends TunesRecord, Record { }
export interface UsersRecordFull extends UsersRecord, Record { }
export interface IniFilesRecordFull extends IniFilesRecord, Record { }

View File

@ -3,17 +3,17 @@ import {
Logs,
TuneWithDetails,
} from '@hyper-tuner/types';
import { TunesResponse } from '../@types/pocketbase-types';
import {
CompositeLogEntry,
ToothLogEntry,
} from '../utils/logs/TriggerLogsParser';
import { TunesRecordFull } from './dbData';
export interface ConfigState extends Config {}
export interface TuneState extends TuneWithDetails {}
export interface TuneDataState extends TunesRecordFull {}
export interface TuneDataState extends TunesResponse {}
export interface LogsState {
fileName: string;