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 install
- run: npm run build - run: npm run build
- name: Sentry Release - name: Sentry Release
uses: getsentry/action-release@v1.2.0 uses: getsentry/action-release@v1.2.1
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

35
.vscode/settings.json vendored
View File

@ -1,13 +1,26 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [ "editor.codeActionsOnSave": {
"hypertuner", "source.fixAll.eslint": true
"kbar", },
"pocketbase", "editor.formatOnSave": true,
"prefs", "cSpell.words": [
"rusefi", "hypertuner",
"typegen", "kbar",
"vite", "pocketbase",
"vitejs" "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 ### Speeduino
[https://tunes.speeduino.com](https://tunes.speeduino.com)
- Source code: [noisymime/speeduino](https://github.com/noisymime/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)

View File

@ -1,28 +1,35 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<link rel="icon" href="/icons/icon.ico" /> <meta charset="utf-8" />
<link rel="apple-touch-icon" href="/icons/icon.png" /> <link rel="icon" href="/icons/icon.ico" />
<link rel="manifest" href="/manifest.json" /> <link rel="apple-touch-icon" href="/icons/icon.png" />
<link href="css/all.min.css" rel="stylesheet"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="preconnect" href="https://apis.google.com" crossorigin>
<link rel="preconnect" href="https://apis.google.com" crossorigin>
<meta property="og:title" content="Speeduino Online Tunes (Powered by HyperTuner Cloud)"> <meta name="description" content="<%- metaDescription %>">
<meta name="twitter:image:alt" content="Speeduino Online Tunes"> <title>
<meta name="description" content="Share your Speeduino tune and logs" /> <%- metaTitle %>
<meta property="og:description" content="The best way to share your Speeduino tunes and logs"> </title>
<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:title" content="<%- metaTitle %>">
<meta property="og:url" content="https://tunes.speeduino.com"> <meta property="og:site_name" content="<%- metaTitle %>">
<meta name="twitter:card" content="summary_large_image"> <meta property="og:description" content="<%- metaDescription %>">
<meta name="description" content="Speeduino Online Tunes - Share your tunes and logs" /> <meta property="og:image" content="<%- metaImage %>">
<title>Speeduino Tune Viewer</title> <meta property="og:url" content="<%- metaUrl %>">
</head>
<body style="background-color: #191C1E"> <meta name="twitter:card" content="summary_large_image">
<noscript>You need to enable JavaScript to run this app.</noscript> <meta name="twitter:image:alt" content="<%- metaTitle %>">
<div id="root"></div>
<!-- Vite entrypoint --> <meta name="theme-color" content="<%- metaThemeColor %>">
<script type="module" src="/src/main.tsx"></script> </head>
</body>
<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> </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": "tsc && eslint --max-warnings=0 src",
"lint:fix": "eslint --fix src", "lint:fix": "eslint --fix src",
"analyze": "npm run build && open stats.html", "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": { "dependencies": {
"@hyper-tuner/ini": "git+https://github.com/hyper-tuner/ini.git", "@hyper-tuner/ini": "git+https://github.com/hyper-tuner/ini.git",
"@hyper-tuner/types": "git+https://github.com/hyper-tuner/types.git", "@hyper-tuner/types": "git+https://github.com/hyper-tuner/types.git",
"@reduxjs/toolkit": "^1.8.6", "@reduxjs/toolkit": "^1.9.0",
"@sentry/react": "^7.17.3", "@sentry/react": "^7.18.0",
"@sentry/tracing": "^7.17.3", "@sentry/tracing": "^7.18.0",
"antd": "^4.23.6", "antd": "^4.24.1",
"kbar": "^0.1.0-beta.36", "kbar": "^0.1.0-beta.37",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"mlg-converter": "^0.8.0", "mlg-converter": "^0.8.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"pako": "^2.0.4", "pako": "^2.1.0",
"pocketbase": "^0.8.0-rc1", "pocketbase": "^0.8.0-rc1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-ga4": "^1.4.1", "react-ga4": "^1.4.1",
"react-markdown": "^8.0.3", "react-markdown": "^8.0.3",
"react-perfect-scrollbar": "^1.5.8", "react-perfect-scrollbar": "^1.5.8",
"react-redux": "^8.0.4", "react-redux": "^8.0.5",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.3",
"uplot": "^1.6.22", "uplot": "^1.6.22",
"uplot-react": "^1.1.1", "uplot-react": "^1.1.1",
"vite": "^3.2.2" "vite": "^3.2.3"
}, },
"devDependencies": { "devDependencies": {
"@hyper-tuner/eslint-config": "git+https://github.com/hyper-tuner/eslint-config.git", "@hyper-tuner/eslint-config": "git+https://github.com/hyper-tuner/eslint-config.git",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/pako": "^2.0.0", "@types/pako": "^2.0.0",
"@types/react": "^18.0.24", "@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8", "@types/react-dom": "^18.0.8",
"@types/react-redux": "^7.1.24", "@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.42.0", "@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.0", "@typescript-eslint/parser": "^5.42.1",
"@vitejs/plugin-react": "^2.2.0", "@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.26.0", "eslint": "^8.27.0",
"eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-jsx-a11y": "^6.6.1",
@ -64,11 +64,11 @@
"eslint-plugin-react": "^7.31.10", "eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"less": "^4.1.3", "less": "^4.1.3",
"pocketbase-typegen": "^1.0.11", "pocketbase-typegen": "^1.0.13",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"rollup-plugin-visualizer": "^5.8.3", "rollup-plugin-visualizer": "^5.8.3",
"typescript": "^4.8.4", "typescript": "^4.8.4",
"vite-plugin-html": "^3.2.0", "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 UserIdString = string
export type BaseRecord = { export type BaseRecord = {
id: RecordIdString id: RecordIdString
created: IsoDateString created: IsoDateString
updated: IsoDateString updated: IsoDateString
collectionId: string collectionId: string
collectionName: string collectionName: string
expand?: { [key: string]: any }
} }
export enum Collections { export enum Collections {
IniFiles = 'iniFiles', IniFiles = 'iniFiles',
Tunes = 'tunes', Stargazers = 'stargazers',
Users = 'users', Tunes = 'tunes',
Users = 'users',
} }
export type IniFilesRecord = { export type IniFilesRecord = {
signature: string signature: string
file: string file: string
ecosystem: 'speeduino' | 'rusefi' ecosystem: 'speeduino' | 'rusefi'
} }
export type IniFilesResponse = IniFilesRecord & BaseRecord
export type StargazersRecord = {
user: RecordIdString
tune: RecordIdString
}
export type StargazersResponse = StargazersRecord & BaseRecord
export type TunesRecord = { export type TunesRecord = {
author: RecordIdString author: RecordIdString
tuneId: string tuneId: string
signature: string signature: string
vehicleName: string stars?: number
engineMake: string vehicleName: string
engineCode: string engineMake: string
displacement: number engineCode: string
cylindersCount: number displacement: number
aspiration: 'na' | 'turbocharged' | 'supercharged' cylindersCount: number
compression?: number aspiration: 'na' | 'turbocharged' | 'supercharged'
fuel?: string compression?: number
ignition?: string fuel?: string
injectorsSize?: number ignition?: string
year?: number injectorsSize?: number
hp?: number year?: number
stockHp?: number hp?: number
readme: string stockHp?: number
textSearch: string readme: string
visibility: 'public' | 'unlisted' textSearch: string
tuneFile: string visibility: 'public' | 'unlisted'
customIniFile?: string tuneFile: string
logFiles?: string[] customIniFile?: string
toothLogFiles?: string[] logFiles?: string[]
toothLogFiles?: string[]
} }
export type TunesResponse = TunesRecord & BaseRecord
export type UsersRecord = { export type UsersRecord = {
avatar?: string avatar?: string
username: string
email: string
verified: boolean
} }
export type UsersResponse = UsersRecord & BaseRecord
export type CollectionRecords = { export type CollectionRecords = {
iniFiles: IniFilesRecord iniFiles: IniFilesRecord
tunes: TunesRecord stargazers: StargazersRecord
users: UsersRecord tunes: TunesRecord
users: UsersRecord
} }

View File

@ -34,12 +34,12 @@ import Info from './pages/Info';
import Hub from './pages/Hub'; import Hub from './pages/Hub';
import { FormRoles } from './pages/auth/Login'; import { FormRoles } from './pages/auth/Login';
import useServerStorage from './hooks/useServerStorage'; import useServerStorage from './hooks/useServerStorage';
import { TunesRecordFull } from './types/dbData';
import TuneParser from './utils/tune/TuneParser'; import TuneParser from './utils/tune/TuneParser';
import standardDialogs from './data/standardDialogs'; import standardDialogs from './data/standardDialogs';
import help from './data/help'; import help from './data/help';
import { import {
iniLoadingError, iniLoadingError,
tuneNotFound,
tuneParsingError, tuneParsingError,
} from './pages/auth/notifications'; } from './pages/auth/notifications';
import { divider } from './data/constants'; import { divider } from './data/constants';
@ -51,6 +51,7 @@ import {
import 'uplot/dist/uPlot.min.css'; import 'uplot/dist/uPlot.min.css';
import 'react-perfect-scrollbar/dist/css/styles.css'; import 'react-perfect-scrollbar/dist/css/styles.css';
import './css/App.less'; import './css/App.less';
import { TunesResponse } from './@types/pocketbase-types';
const Tune = lazy(() => import('./pages/Tune')); const Tune = lazy(() => import('./pages/Tune'));
const Logs = lazy(() => import('./pages/Logs')); const Logs = lazy(() => import('./pages/Logs'));
@ -82,7 +83,7 @@ const App = ({ ui, tuneData }: { ui: UIState, tuneData: TuneDataState }) => {
const tuneId = tunePathMatch?.params.tuneId; const tuneId = tunePathMatch?.params.tuneId;
const { fetchINIFile, fetchTuneFile } = useServerStorage(); const { fetchINIFile, fetchTuneFile } = useServerStorage();
const loadTune = async (data: TunesRecordFull | null) => { const loadTune = async (data: TunesResponse | null) => {
if (data === null) { if (data === null) {
store.dispatch({ type: 'config/load', payload: null }); store.dispatch({ type: 'config/load', payload: null });
store.dispatch({ type: 'tune/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 }); store.dispatch({ type: 'tune/load', payload: tune });
} catch (error) { } catch (error) {
iniLoadingError((error as 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) => { getTune(tuneId).then(async (tune) => {
if (!tune) { if (!tune) {
console.warn('Tune not found'); tuneNotFound();
navigate(Routes.HUB); navigate(Routes.HUB);
return; 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, Col,
Tooltip, Tooltip,
Grid, Grid,
Menu,
Dropdown, Dropdown,
Typography, Typography,
Radio, Radio,
@ -304,9 +303,7 @@ const TopBar = ({
</Button> </Button>
</Link> </Link>
{tuneData?.tuneId && <Dropdown {tuneData?.tuneId && <Dropdown
overlay={ menu={{ items: downloadItems }}
<Menu triggerSubMenuAction="click" items={downloadItems} />
}
placement="bottom" placement="bottom"
trigger={['click']} trigger={['click']}
> >
@ -315,7 +312,7 @@ const TopBar = ({
</Button> </Button>
</Dropdown>} </Dropdown>}
<Dropdown <Dropdown
overlay={<Menu items={userMenuItems} />} menu={{ items: userMenuItems }}
placement="bottomRight" placement="bottomRight"
trigger={['click']} trigger={['click']}
> >

View File

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

View File

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

View File

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

View File

@ -3,23 +3,38 @@ import {
client, client,
formatError, formatError,
ClientResponseError, ClientResponseError,
API_URL,
} from '../pocketbase'; } from '../pocketbase';
import {
IniFilesRecordFull,
TunesRecordFull,
TunesRecordPartial,
} from '../types/dbData';
import { databaseGenericError } from '../pages/auth/notifications'; import { databaseGenericError } from '../pages/auth/notifications';
import { import {
Collections, Collections,
IniFilesResponse,
TunesRecord, TunesRecord,
TunesResponse,
} from '../@types/pocketbase-types'; } 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 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 useDb = () => {
const updateTune = async (id: string, data: TunesRecordPartial) => { const updateTune = async (id: string, data: TunesRecordPartial): Promise<void> => {
try { try {
await tunesCollection.update(id, data); await tunesCollection.update(id, data);
return Promise.resolve(); return Promise.resolve();
@ -31,11 +46,11 @@ const useDb = () => {
} }
}; };
const createTune = async (data: TunesRecord) => { const createTune = async (data: TunesRecord): Promise<TunesResponse> => {
try { 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) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
databaseGenericError(new Error(formatError(error))); databaseGenericError(new Error(formatError(error)));
@ -44,54 +59,41 @@ const useDb = () => {
} }
}; };
const getTune = async (tuneId: string) => { const getTune = async (tuneId: string): Promise<TunesResponse | null> => {
try { const response = await fetch(`${customEndpoint}/tunes/byTuneId/${tuneId}`);
const tune = await tunesCollection.getFirstListItem(
`tuneId = "${tuneId}"`,
{
expand: 'author',
},
);
return Promise.resolve(tune as TunesRecordFull); if (response.ok) {
} catch (error) { return response.json();
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.status === 404) {
return Promise.resolve(null);
}
Sentry.captureException(response);
databaseGenericError(new Error(response.statusText));
return Promise.reject(response.status);
}; };
const getIni = async (signature: string) => { const getIni = async (signature: string): Promise<IniFilesResponse | null> => {
try { const response = await fetch(`${customEndpoint}/iniFiles/bySignature/${signature}`);
const ini = await iniFilesCollection.getFirstListItem(`signature = "${signature}"`);
return Promise.resolve(ini as IniFilesRecordFull); if (response.ok) {
} catch (error) { return response.json();
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.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 phrases = search.length > 0 ? search.replace(/ +(?= )/g, '').split(' ') : [];
const filter = phrases const filter = phrases
.filter((phrase) => phrase.length > 1) .filter((phrase) => phrase.length > 1)
@ -99,14 +101,14 @@ const useDb = () => {
.join(' && '); .join(' && ');
try { try {
const list = await tunesCollection.getList(page, perPage, { const list = await tunesCollection.getList<TunesResponse>(page, perPage, {
sort: '-updated', sort: '-stars,-updated',
filter, filter,
expand: 'author', expand: 'author',
}); });
return Promise.resolve({ return Promise.resolve({
items: list.items as TunesRecordFull[], items: list.items,
totalItems: list.totalItems, totalItems: list.totalItems,
}); });
} catch (error) { } 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 { try {
const list = await tunesCollection.getList(page, perPage, { const list = await tunesCollection.getList<TunesResponse>(page, perPage, {
sort: '-updated', sort: '-updated',
filter: `author = "${userId}"`, filter: `author = "${userId}"`,
expand: 'author', expand: 'author',
}); });
return Promise.resolve({ return Promise.resolve({
items: list.items as TunesRecordFull[], items: list.items,
totalItems: list.totalItems, totalItems: list.totalItems,
}); });
} catch (error) { } 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 { try {
const items = await tunesCollection.getFullList(10, { const items = await tunesCollection.getFullList<TunesResponse>(10, {
filter: `${attribute} ~ "${search}"`, filter: `${attribute} ~ "${search}"`,
}); });
return Promise.resolve(items as TunesRecordFull[]); return Promise.resolve(items);
} catch (error) { } catch (error) {
if ((error as ClientResponseError).isAbort) { if ((error as ClientResponseError).isAbort) {
return Promise.reject(new Error('Cancelled')); 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 { return {
updateTune: (tuneId: string, data: TunesRecordPartial): Promise<void> => updateTune(tuneId, data), updateTune,
createTune: (data: TunesRecord): Promise<TunesRecordFull> => createTune(data), createTune,
getTune: (tuneId: string): Promise<TunesRecordFull | null> => getTune(tuneId), getTune,
getIni: (tuneId: string): Promise<IniFilesRecordFull | null> => getIni(tuneId), getIni,
searchTunes: (search: string, page: number, perPage: number): Promise<{ items: TunesRecordFull[]; totalItems: number }> => searchTunes(search, page, perPage), searchTunes,
getUserTunes: (userId: string, page: number, perPage: number): Promise<{ items: TunesRecordFull[]; totalItems: number }> => getUserTunes(userId, page, perPage), getUserTunes,
autocomplete: (attribute: string, search: string): Promise<TunesRecordFull[]> => autocomplete(attribute, search), autocomplete,
toggleStar,
isStarredByMe,
}; };
}; };

View File

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

View File

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

View File

@ -22,7 +22,8 @@ import Loader from '../components/Loader';
import { Routes } from '../routes'; import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { formatTime } from '../utils/time'; 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 { Item } = Form;
const rowProps = { gutter: 10 }; const rowProps = { gutter: 10 };
@ -66,12 +67,13 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
return ( return (
<div className="small-container"> <div className="small-container">
<StarButton tuneData={tuneData} />
<Divider>Details</Divider> <Divider>Details</Divider>
<Form> <Form>
<Row {...rowProps}> <Row {...rowProps}>
<Col {...colProps}> <Col {...colProps}>
<Item> <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> </Item>
</Col> </Col>
<Col {...colProps}> <Col {...colProps}>

View File

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

View File

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

View File

@ -21,9 +21,9 @@ import { formatTime } from '../utils/time';
import useDb from '../hooks/useDb'; import useDb from '../hooks/useDb';
import { aspirationMapper } from '../utils/tune/mappers'; import { aspirationMapper } from '../utils/tune/mappers';
import { import {
TunesRecordFull, TunesResponse,
UsersRecordFull, UsersResponse,
} from '../types/dbData'; } from '../@types/pocketbase-types';
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId }); const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
@ -35,22 +35,22 @@ const Profile = () => {
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [isTunesLoading, setIsTunesLoading] = useState(false); const [isTunesLoading, setIsTunesLoading] = useState(false);
const [tunesDataSource, setTunesDataSource] = useState<TunesRecordFull[]>([]); const [tunesDataSource, setTunesDataSource] = useState<TunesResponse[]>([]);
const [username, setUsername] = useState(); const [username, setUsername] = useState<string>();
const loadData = async () => { const loadData = async () => {
setIsTunesLoading(true); setIsTunesLoading(true);
try { try {
const { items, totalItems } = await getUserTunes(route?.params.userId!, page, pageSize); const { items, totalItems } = await getUserTunes(route?.params.userId!, page, pageSize);
setTotal(totalItems); setTotal(totalItems);
setUsername((items[0].expand.author as UsersRecordFull).username); setUsername((items[0]!.expand!.author as UsersResponse).username);
const mapped = items.map((tune) => ({ const mapped = items.map((tune) => ({
...tune, ...tune,
key: tune.tuneId, key: tune.tuneId,
year: tune.year, year: tune.year,
displacement: `${tune.displacement}l`, displacement: `${tune.displacement}l`,
aspiration: aspirationMapper[tune.aspiration], aspiration: aspirationMapper[tune.aspiration],
published: formatTime(tune.updated), updated: formatTime(tune.updated),
})); }));
setTunesDataSource(mapped as any); setTunesDataSource(mapped as any);
} catch (error) { } catch (error) {
@ -88,7 +88,7 @@ const Profile = () => {
</>} </>}
/> />
<div> <div>
<Typography.Text italic>{tune.published}</Typography.Text> <Typography.Text italic>{tune.updated}</Typography.Text>
</div> </div>
</Space> </Space>
</List.Item> </List.Item>

View File

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

View File

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

View File

@ -1,7 +1,4 @@
import PocketBase, { import PocketBase, { ClientResponseError } from 'pocketbase';
ClientResponseError,
Record,
} from 'pocketbase';
import { fetchEnv } from './utils/env'; import { fetchEnv } from './utils/env';
const API_URL = fetchEnv('VITE_POCKETBASE_API_URL'); const API_URL = fetchEnv('VITE_POCKETBASE_API_URL');
@ -28,5 +25,4 @@ export {
formatError, formatError,
removeFilenameSuffix, removeFilenameSuffix,
ClientResponseError, 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, Logs,
TuneWithDetails, TuneWithDetails,
} from '@hyper-tuner/types'; } from '@hyper-tuner/types';
import { TunesResponse } from '../@types/pocketbase-types';
import { import {
CompositeLogEntry, CompositeLogEntry,
ToothLogEntry, ToothLogEntry,
} from '../utils/logs/TriggerLogsParser'; } from '../utils/logs/TriggerLogsParser';
import { TunesRecordFull } from './dbData';
export interface ConfigState extends Config {} export interface ConfigState extends Config {}
export interface TuneState extends TuneWithDetails {} export interface TuneState extends TuneWithDetails {}
export interface TuneDataState extends TunesRecordFull {} export interface TuneDataState extends TunesResponse {}
export interface LogsState { export interface LogsState {
fileName: string; fileName: string;