hyper-tuner-cloud/src/pages/Upload.tsx

928 lines
27 KiB
TypeScript

import {
useCallback,
useEffect,
useState,
} from 'react';
import {
Button,
Col,
Divider,
Input,
InputNumber,
Row,
Select,
Space,
Tabs,
Tooltip,
Typography,
Upload,
Form,
AutoComplete,
} from 'antd';
import {
PlusOutlined,
ToolOutlined,
FundOutlined,
SettingOutlined,
CopyOutlined,
ShareAltOutlined,
FileTextOutlined,
EditOutlined,
CheckOutlined,
SendOutlined,
GlobalOutlined,
EyeOutlined,
} from '@ant-design/icons';
import debounce from 'lodash.debounce';
import { INI } from '@hyper-tuner/ini';
import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface';
import {
generatePath,
useMatch,
useNavigate,
} from 'react-router-dom';
import Pako from 'pako';
import ReactMarkdown from 'react-markdown';
import { nanoid } from 'nanoid';
import {
emailNotVerified,
error,
restrictedPage,
signatureNotSupportedWarning,
} from './auth/notifications';
import { useAuth } from '../contexts/AuthContext';
import { Routes } from '../routes';
import TuneParser from '../utils/tune/TuneParser';
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
import LogValidator from '../utils/logs/LogValidator';
import useDb, { TunesRecordPartial } from '../hooks/useDb';
import useServerStorage from '../hooks/useServerStorage';
import { buildFullUrl } from '../utils/url';
import Loader from '../components/Loader';
import {
requiredTextRules,
requiredRules,
} from '../utils/form';
import { aspirationMapper } from '../utils/tune/mappers';
import { copyToClipboard } from '../utils/clipboard';
import {
TunesRecord,
TunesResponse,
} from '../@types/pocketbase-types';
import { removeFilenameSuffix } from '../pocketbase';
const { Item, useForm } = Form;
enum MaxFiles {
TUNE_FILES = 1,
LOG_FILES = 5,
TOOTH_LOG_FILES = 5,
CUSTOM_INI_FILES = 1,
}
interface ValidationResult {
result: boolean;
message: string;
}
interface UploadedFile extends File {
uid: string | undefined;
}
type ValidateFile = (file: File) => Promise<ValidationResult>;
const rowProps = { gutter: 10 };
const colProps = { span: 24, sm: 12 };
const maxFileSizeMB = 50;
const descriptionEditorHeight = 260;
const thisYear = (new Date()).getFullYear();
const generateTuneId = () => nanoid(10);
const defaultReadme = '# My Tune\n\ndescription';
const tuneIcon = () => <ToolOutlined />;
const logIcon = () => <FundOutlined />;
const toothLogIcon = () => <SettingOutlined />;
const iniIcon = () => <FileTextOutlined />;
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
const tuneParser = new TuneParser();
const bufferToFile = (buffer: ArrayBuffer, name: string) => new File([buffer], name);
const UploadPage = () => {
const [form] = useForm();
const routeMatch = useMatch(Routes.UPLOAD_WITH_TUNE_ID);
const [isLoading, setIsLoading] = useState(false);
const [isTuneLoading, setTuneIsLoading] = useState(true);
const [newTuneId, setNewTuneId] = useState<string>();
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
const [shareUrl, setShareUrl] = useState<string>();
const [isPublished, setIsPublished] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [readme, setReadme] = useState(defaultReadme);
const [existingTune, setExistingTune] = useState<TunesResponse>();
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
const [defaultToothLogFilesList, setDefaultToothLogFilesList] = useState<UploadFile[]>([]);
const [defaultCustomIniFileList, setDefaultCustomIniFileList] = useState<UploadFile[]>([]);
const [tuneFile, setTuneFile] = useState<File>();
const [customIniFile, setCustomIniFile] = useState<File>();
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();
const { fetchTuneFile, fetchINIFile } = useServerStorage();
const { createTune, updateTune, getTune, autocomplete } = useDb();
const [autocompleteOptions, setAutocompleteOptions] = useState<{ [attribute: string]: { value: string }[] }>({});
const searchAutocomplete = debounce(async (attribute: string, search: string) => {
if (search.length === 0) {
setAutocompleteOptions((prev) => ({ ...prev, [attribute]: [] }));
return;
}
const options = (await autocomplete(attribute, search))
.map((record) => (record as any)[attribute]);
// TODO: order by occurrence (more common - higher in the list)
const unique = [...new Set(options)].map((value) => ({ value }));
setAutocompleteOptions((prev) => ({ ...prev, [attribute]: unique }));
}, 300);
const fetchFile = async (tuneId: string, fileName: string) => bufferToFile(await fetchTuneFile(tuneId, fileName), fileName);
const noop = () => { };
const goToNewTune = () => navigate(generatePath(Routes.TUNE_TUNE, {
tuneId: newTuneId!,
}));
const publishTune = async (values: any) => {
setIsLoading(true);
/* eslint-disable prefer-destructuring */
const vehicleName = values.vehicleName.trim();
const engineMake = values.engineMake.trim();
const engineCode = values.engineCode.trim();
const displacement = values.displacement;
const cylindersCount = values.cylindersCount;
const aspiration = values.aspiration.trim();
const compression = values.compression || null;
const fuel = values.fuel?.trim() || null;
const ignition = values.ignition?.trim() || null;
const injectorsSize = values.injectorsSize || null;
const year = values.year || null;
const hp = values.hp || null;
const stockHp = values.stockHp || null;
const visibility = values.visibility;
/* eslint-enable prefer-destructuring */
const compressedTuneFile = bufferToFile(
Pako.deflate(await tuneFile!.arrayBuffer()),
(tuneFile as UploadedFile).uid ? tuneFile!.name : removeFilenameSuffix(tuneFile!.name),
);
const compressedCustomIniFile = customIniFile ? bufferToFile(
Pako.deflate(await customIniFile!.arrayBuffer()),
(customIniFile as UploadedFile).uid ? customIniFile!.name : removeFilenameSuffix(customIniFile!.name),
) : null;
const compressedLogFiles = await Promise.all(logFiles.map(async (file) => bufferToFile(
Pako.deflate(await file.arrayBuffer()),
(file as UploadedFile).uid ? file.name : removeFilenameSuffix(file.name),
)));
const compressedToothLogFiles = await Promise.all(toothLogFiles.map(async (file) => bufferToFile(
Pako.deflate(await file.arrayBuffer()),
(file as UploadedFile).uid ? file.name : removeFilenameSuffix(file.name),
)));
const { signature } = tuneParser.parse(await tuneFile!.arrayBuffer()).getTune().details;
const newData: TunesRecord = {
author: currentUser!.id,
tuneId: newTuneId!,
signature,
vehicleName,
engineMake,
engineCode,
displacement,
cylindersCount,
aspiration,
compression,
fuel,
ignition,
injectorsSize,
year,
hp,
stockHp,
readme: readme?.trim(),
visibility,
tuneFile: compressedTuneFile as unknown as string,
customIniFile: compressedCustomIniFile as unknown as string,
logFiles: compressedLogFiles as unknown as string[],
toothLogFiles: compressedToothLogFiles as unknown as string[],
textSearch: [
signature,
vehicleName,
engineMake,
engineCode,
`${displacement}l`,
aspirationMapper[aspiration] || null,
fuel,
ignition,
year,
].filter((field) => field !== null && `${field}`.length > 1 && field !== 'null')
.join(' ')
.replace(/[^\w.\-\d ]/g, ''),
};
const formData = new FormData();
Object.keys(newData).forEach((key) => {
const value = (newData as any)[key];
if (Array.isArray(value)) {
value.forEach((file: File) => {
formData.append(key, file);
});
} else {
formData.append(key, value);
}
});
if (existingTune) {
// 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', '');
await updateTune(existingTune.id, tempFormData as unknown as TunesRecord);
// another update with new files
await updateTune(existingTune.id, formData as unknown as TunesRecord);
} else {
await createTune(formData as unknown as TunesRecord);
}
setIsLoading(false);
setIsPublished(true);
};
const validateSize = (file: File) => Promise.resolve({
result: (file.size / 1024 / 1024) <= maxFileSizeMB,
message: `File should not be larger than ${maxFileSizeMB}MB!`,
});
const navigateToNewTuneId = useCallback(() => {
navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
tuneId: generateTuneId(),
}), { replace: true });
}, [navigate]);
const upload = async (options: UploadRequestOption, done: (file: File) => void, validate: ValidateFile) => {
const { onError, onSuccess, file } = options;
const validation = await validate(file as File);
if (!validation.result) {
const errorName = 'Validation failed';
const errorMessage = validation.message;
error(errorName, errorMessage);
onError!({ name: errorName, message: errorMessage });
return false;
}
done(file as File);
onSuccess!(null);
return true;
};
const uploadTune = async (options: UploadRequestOption) => {
upload(options, async (file) => {
setTuneFile(file);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
return { result, message };
}
const parsed = tuneParser.parse(await file.arrayBuffer());
const { signature } = parsed.getTune().details;
if (!parsed.isValid()) {
return {
result: false,
message: 'Tune file is not valid or not supported!',
};
}
try {
await fetchINIFile(signature);
} catch (e) {
setCustomIniRequired(true);
signatureNotSupportedWarning((e as Error).message);
return {
result: true,
message: '',
};
}
setCustomIniRequired(false);
return {
result: true,
message: '',
};
});
};
const uploadLogs = async (options: UploadRequestOption) => {
upload(options, async (file) => {
setLogFiles((prev) => [...prev, file]);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
return { result, message };
}
let valid = true;
const extension = file.name.split('.').pop();
const parser = new LogValidator(await file.arrayBuffer());
switch (extension) {
case 'mlg':
valid = parser.isMLG();
break;
case 'msl':
case 'csv':
valid = parser.isMSL();
break;
default:
valid = false;
break;
}
return {
result: valid,
message: 'Log file is empty or not valid!',
};
});
};
const uploadToothLogs = async (options: UploadRequestOption) => {
upload(options, async (file) => {
setToothLogFiles((prev) => [...prev, file]);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
return { result, message };
}
const parser = new TriggerLogsParser(await file.arrayBuffer());
return {
result: parser.isComposite() || parser.isTooth(),
message: 'Tooth logs file is empty or not valid!',
};
});
};
const uploadCustomIni = async (options: UploadRequestOption) => {
upload(options, async (file) => {
setCustomIniFile(file);
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
return { result, message };
}
let validationMessage = 'INI file is empty or not valid!';
let valid = false;
try {
const parser = new INI(await file.arrayBuffer()).parse();
valid = parser.getResults().megaTune.signature.length > 0;
} catch (e) {
valid = false;
validationMessage = (e as Error).message;
}
if (valid) {
setCustomIniRequired(false);
}
return {
result: valid,
message: validationMessage,
};
});
};
const removeTuneFile = async () => {
setTuneFile(undefined);
};
const removeLogFile = async (file: UploadFile) => {
setLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeToothLogFile = async (file: UploadFile) => {
setToothLogFiles((prev) => prev.filter((f) => removeFilenameSuffix(f.name) !== file.name));
};
const removeCustomIniFile = async (file: UploadFile) => {
setCustomIniFile(undefined);
};
const loadExistingTune = useCallback(async (currentTuneId: string) => {
setNewTuneId(currentTuneId);
const oldTune = await getTune(currentTuneId);
if (oldTune) {
// this is someone elses tune
if (oldTune.author !== currentUser?.id) {
navigateToNewTuneId();
return;
}
setExistingTune(oldTune);
form.setFieldsValue(oldTune);
setIsEditMode(true);
setReadme(oldTune.readme!);
if (oldTune.tuneFile) {
setTuneFile(await fetchFile(oldTune.id, oldTune.tuneFile));
setDefaultTuneFileList([{
uid: oldTune.tuneFile,
name: removeFilenameSuffix(oldTune.tuneFile),
status: 'done',
}]);
}
if (oldTune.customIniFile) {
setCustomIniFile(await fetchFile(oldTune.id, oldTune.customIniFile));
setDefaultCustomIniFileList([{
uid: oldTune.customIniFile,
name: removeFilenameSuffix(oldTune.customIniFile),
status: 'done',
}]);
}
const tempLogFiles: File[] = [];
oldTune.logFiles?.forEach(async (fileName: string) => {
tempLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultLogFilesList((prev) => [...prev, {
uid: fileName,
name: removeFilenameSuffix(fileName),
status: 'done',
}]);
});
setLogFiles(tempLogFiles);
const tempToothLogFiles: File[] = [];
oldTune.toothLogFiles?.forEach(async (fileName: string) => {
tempToothLogFiles.push(await fetchFile(oldTune.id, fileName));
setDefaultToothLogFilesList((prev) => [...prev, {
uid: fileName,
name: removeFilenameSuffix(fileName),
status: 'done',
}]);
});
setToothLogFiles(tempToothLogFiles);
} else {
// reset state
form.resetFields();
setReadme(defaultReadme);
setTuneFile(undefined);
setCustomIniFile(undefined);
setDefaultTuneFileList([]);
setDefaultLogFilesList([]);
setDefaultToothLogFilesList([]);
setDefaultCustomIniFileList([]);
}
setTuneIsLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser, form, navigateToNewTuneId]);
const prepareData = useCallback(async () => {
const currentTuneId = routeMatch?.params.tuneId;
if (currentTuneId) {
loadExistingTune(currentTuneId);
setShareUrl(buildFullUrl([tunePath(currentTuneId)]));
} else {
navigateToNewTuneId();
}
}, [loadExistingTune, navigateToNewTuneId, routeMatch?.params.tuneId]);
useEffect(() => {
refreshUser().then((user) => {
if (user === null) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
if (!user) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
if (!user.verified) {
emailNotVerified();
navigate(Routes.PROFILE);
return;
}
setIsUserAuthorized(true);
prepareData();
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeMatch?.params.tuneId]);
const uploadButton = (
<Space direction="vertical">
<PlusOutlined />Upload
</Space>
);
const publishButtonText = () => {
if (customIniRequired) {
return 'Custom INI file required!';
}
return isEditMode ? 'Update' : 'Publish';
};
const publishButton = (
<Row style={{ marginTop: 10 }} {...rowProps}>
<Col {...colProps}>
<Item name="visibility">
<Select>
<Select.Option value="public">
<Space><GlobalOutlined />Public</Space>
</Select.Option>
<Select.Option value="unlisted">
<Space><EyeOutlined />Unlisted</Space>
</Select.Option>
</Select>
</Item>
</Col>
<Col {...colProps}>
<Item style={{ width: '100%' }}>
<Button
type="primary"
block
loading={isLoading}
htmlType="submit"
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
disabled={customIniRequired}
>
{publishButtonText()}
</Button>
</Item>
</Col>
</Row>
);
const openButton = (
<>
<Row>
<Input
style={{ width: `calc(100% - ${shareSupported ? 65 : 35}px)` }}
value={shareUrl!}
/>
<Tooltip title="Copy URL">
<Button icon={<CopyOutlined />} onClick={() => copyToClipboard(shareUrl!)} />
</Tooltip>
{shareSupported && (
<Tooltip title="Share">
<Button
icon={<ShareAltOutlined />}
onClick={() => navigator.share({ url: shareUrl! })}
/>
</Tooltip>
)}
</Row>
<Row style={{ marginTop: 10 }}>
<Item style={{ width: '100%' }}>
<Button
type="primary"
block
onClick={goToNewTune}
icon={<SendOutlined />}
>
Open
</Button>
</Item>
</Row>
</>
);
const shareSection = (
<>
<Divider>Publish & Share</Divider>
{isPublished ? openButton : publishButton}
</>
);
const detailsSection = (
<>
<Divider>
<Space>Details</Space>
</Divider>
<Row {...rowProps}>
<Col span={24} sm={24}>
<Item name="vehicleName" rules={requiredTextRules}>
<AutoComplete
options={autocompleteOptions.vehicleName}
onSearch={(search) => searchAutocomplete('vehicleName', search)}
backfill
>
<Input addonBefore="Vehicle name" />
</AutoComplete>
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="engineMake" rules={requiredTextRules}>
<AutoComplete
options={autocompleteOptions.engineMake}
onSearch={(search) => searchAutocomplete('engineMake', search)}
backfill
>
<Input addonBefore="Engine make" />
</AutoComplete>
</Item>
</Col>
<Col {...colProps}>
<Item name="engineCode" rules={requiredTextRules}>
<AutoComplete
options={autocompleteOptions.engineCode}
onSearch={(search) => searchAutocomplete('engineCode', search)}
backfill
>
<Input addonBefore="Engine code" />
</AutoComplete>
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="displacement" rules={requiredRules}>
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
</Item>
</Col>
<Col {...colProps}>
<Item name="cylindersCount" rules={requiredRules}>
<InputNumber addonBefore="Cylinders" style={{ width: '100%' }} min={0} max={16} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="aspiration">
<Select placeholder="Aspiration" style={{ width: '100%' }}>
<Select.Option value="na">Naturally aspirated</Select.Option>
<Select.Option value="turbocharged">Turbocharged</Select.Option>
<Select.Option value="supercharged">Supercharged</Select.Option>
</Select>
</Item>
</Col>
<Col {...colProps}>
<Item name="compression">
<InputNumber addonBefore="Compression" style={{ width: '100%' }} min={0} max={100} step={0.1} addonAfter=":1" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="fuel">
<AutoComplete
options={autocompleteOptions.fuel}
onSearch={(search) => searchAutocomplete('fuel', search)}
backfill
>
<Input addonBefore="Fuel" />
</AutoComplete>
</Item>
</Col>
<Col {...colProps}>
<Item name="ignition">
<AutoComplete
options={autocompleteOptions.ignition}
onSearch={(search) => searchAutocomplete('ignition', search)}
backfill
>
<Input addonBefore="Ignition" />
</AutoComplete>
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="injectorsSize">
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} max={100_000} />
</Item>
</Col>
<Col {...colProps}>
<Item name="year">
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="hp">
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} max={100_000} />
</Item>
</Col>
<Col {...colProps}>
<Item name="stockHp">
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} max={100_000} />
</Item>
</Col>
</Row>
<Divider style={{ marginTop: 40 }}>
<Space>
README
<Typography.Text type="secondary">(markdown)</Typography.Text>
</Space>
</Divider>
<Tabs
defaultActiveKey="source"
className="upload-readme"
items={[
{
label: 'Edit',
key: 'source',
style: { height: descriptionEditorHeight },
children: (
<Input.TextArea
rows={10}
showCount
value={readme}
onChange={(e) => setReadme(e.target.value)}
maxLength={3_000}
/>
),
},
{
label: 'Preview',
key: 'preview',
style: { height: descriptionEditorHeight },
children: (
<div className="markdown-preview">
<ReactMarkdown>
{readme}
</ReactMarkdown>
</div>
),
},
]}
/>
</>
);
const optionalSection = (
<>
<Divider>
<Space>
Upload Logs
<Typography.Text type="secondary">(.mlg, .csv, .msl)</Typography.Text>
</Space>
</Divider>
<Upload
key={defaultLogFilesList.map((file) => file.uid).join('-') || 'logs'}
listType="picture-card"
customRequest={uploadLogs}
onRemove={removeLogFile}
iconRender={logIcon}
multiple
maxCount={MaxFiles.LOG_FILES}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultLogFilesList}
accept=".mlg,.csv,.msl"
>
{logFiles.length < MaxFiles.LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
Upload Tooth and Composite logs
<Typography.Text type="secondary">(.csv)</Typography.Text>
</Space>
</Divider>
<Upload
key={defaultToothLogFilesList.map((file) => file.uid).join('-') || 'toothLogs'}
listType="picture-card"
customRequest={uploadToothLogs}
onRemove={removeToothLogFile}
iconRender={toothLogIcon}
multiple
maxCount={MaxFiles.TOOTH_LOG_FILES}
onPreview={noop}
defaultFileList={defaultToothLogFilesList}
accept=".csv"
>
{toothLogFiles.length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
Upload Custom INI
<Typography.Text type="secondary">(.ini)</Typography.Text>
</Space>
</Divider>
<Upload
key={defaultCustomIniFileList[0]?.uid || 'customIni'}
listType="picture-card"
customRequest={uploadCustomIni}
onRemove={removeCustomIniFile}
iconRender={iniIcon}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultCustomIniFileList}
accept=".ini"
>
{!customIniFile && uploadButton}
</Upload>
{detailsSection}
{shareUrl && tuneFile && shareSection}
</>
);
if (isPublished) {
return (
<div className="small-container">
{shareSection}
</div>
);
}
if (!isUserAuthorized || isTuneLoading) {
return (
<Form form={form}>
<Loader />
</Form>
);
}
return (
<div className="small-container">
<Form
initialValues={{
aspiration: 'na',
readme,
visibility: 'public',
cylindersCount: 4,
displacement: 1.6,
year: thisYear,
} as TunesRecordPartial}
form={form}
onFinish={publishTune}
>
<Divider>
<Space>
Upload Tune
<Typography.Text type="secondary">(.msq)</Typography.Text>
</Space>
</Divider>
<Upload
key={defaultTuneFileList[0]?.uid || 'tuneFile'}
listType="picture-card"
customRequest={uploadTune}
onRemove={removeTuneFile}
iconRender={tuneIcon}
disabled={isPublished}
onPreview={noop}
defaultFileList={defaultTuneFileList}
accept=".msq"
>
{tuneFile === undefined && uploadButton}
</Upload>
{(tuneFile || defaultTuneFileList.length > 0) && optionalSection}
</Form>
</div>
);
};
export default UploadPage;