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

709 lines
20 KiB
TypeScript

import {
useCallback,
useEffect,
useState,
} from 'react';
import {
Button,
Col,
Divider,
Input,
InputNumber,
notification,
Row,
Select,
Space,
Switch,
Tabs,
Tooltip,
Typography,
Upload,
Form,
} from 'antd';
import {
PlusOutlined,
ToolOutlined,
FundOutlined,
SettingOutlined,
CopyOutlined,
ShareAltOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import * as Sentry from '@sentry/browser';
import { INI } from '@hyper-tuner/ini';
import { UploadRequestOption } from 'rc-upload/lib/interface';
import { UploadFile } from 'antd/lib/upload/interface';
import {
generatePath,
useNavigate,
} from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import {
customAlphabet,
nanoid,
} from 'nanoid';
import {
emailNotVerified,
restrictedPage,
} 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 LogParser from '../utils/logs/LogParser';
import useDb from '../hooks/useDb';
import useServerStorage from '../hooks/useServerStorage';
import { generateShareUrl } from '../utils/url';
import Loader from '../components/Loader';
const { Item } = Form;
enum MaxFiles {
TUNE_FILES = 1,
LOG_FILES = 5,
TOOTH_LOG_FILES = 5,
CUSTOM_INI_FILES = 1,
}
type Path = string;
interface UploadedFile {
[autoUid: string]: Path;
}
interface UploadFileData {
path: string;
}
interface ValidationResult {
result: boolean;
message: string;
}
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 nanoidCustom = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
const tuneIcon = () => <ToolOutlined />;
const logIcon = () => <FundOutlined />;
const toothLogIcon = () => <SettingOutlined />;
const iniIcon = () => <FileTextOutlined />;
const UploadPage = () => {
const [newTuneId, setNewTuneId] = useState<string>();
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
const [shareUrl, setShareUrl] = useState<string>();
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isPublished, setIsPublished] = useState(false);
const [tuneFile, setTuneFile] = useState<UploadedFile | null | false>(null);
const [logFiles, setLogFiles] = useState<UploadedFile>({});
const [toothLogFiles, setToothLogFiles] = useState<UploadedFile>({});
const [customIniFile, setCustomIniFile] = useState<UploadedFile | null>(null);
const hasNavigatorShare = navigator.share !== undefined;
const { currentUser, refreshToken } = useAuth();
const navigate = useNavigate();
const { removeFile, uploadFile, basePathForFile } = useServerStorage();
const { updateData } = useDb();
const requiredRules = [{ required: true, message: 'This field is required!' }];
const [readme, setReadme] = useState('# My Tune\n\ndescription');
const noop = () => { };
const goToNewTune = () => navigate(generatePath(Routes.TUNE_ROOT, {
tuneId: newTuneId!,
}));
const copyToClipboard = async () => {
if (navigator.clipboard) {
await navigator.clipboard.writeText(shareUrl!);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}
};
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
const publish = async (values: any) => {
setIsLoading(true);
await updateData(newTuneId!, {
id: newTuneId!,
userUid: currentUser!.uid,
updatedAt: new Date(),
isPublished: true,
isListed: values.isListed,
details: {
readme: readme || null,
make: values.make || null,
model: values.model || null,
displacement: values.displacement || null,
year: values.year || null,
hp: values.hp || null,
stockHp: values.stockHp || null,
engineCode: values.engineCode || null,
cylindersCount: values.cylindersCount || null,
aspiration: values.aspiration || null,
fuel: values.fuel || null,
injectorsSize: values.injectorsSize || null,
coils: values.coils || null,
},
});
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 upload = async (path: string, options: UploadRequestOption, done: Function, validate: ValidateFile) => {
const { onError, onSuccess, onProgress, file } = options;
const validation = await validate(file as File);
if (!validation.result) {
const errorName = 'Validation failed';
const errorMessage = validation.message;
notification.error({ message: errorName, description: errorMessage });
onError!({ name: errorName, message: errorMessage });
return false;
}
try {
const pako = await import('pako');
const buffer = await (file as File).arrayBuffer();
const compressed = pako.deflate(new Uint8Array(buffer));
const uploadTask = uploadFile(path, file as File, compressed);
uploadTask.on(
'state_changed',
(snap) => onProgress!({ percent: (snap.bytesTransferred / snap.totalBytes) * 100 }),
(err) => onError!(err),
() => {
onSuccess!(file);
if (done) done();
},
);
} catch (error) {
Sentry.captureException(error);
console.error('Upload error:', error);
notification.error({ message: 'Upload error', description: (error as Error).message });
onError!(error as Error);
}
return true;
};
const tuneFileData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `tune/${nanoid()}.msq.gz`),
});
const logFileData = (file: UploadFile) => {
const { name } = file;
const extension = name.split('.').pop();
return {
path: basePathForFile(currentUser!.uid, newTuneId!, `logs/${nanoid()}.${extension}.gz`),
};
};
const toothLogFilesData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `tooth-logs/${nanoid()}.csv.gz`),
});
const customIniFileData = () => ({
path: basePathForFile(currentUser!.uid, newTuneId!, `ini/${nanoid()}.ini.gz`),
});
const uploadTune = async (options: UploadRequestOption) => {
setShareUrl(generateShareUrl(newTuneId!));
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
upload(path, options, () => {
// this is `create` for firebase
// initialize data
updateData(newTuneId!, {
id: newTuneId!,
userUid: currentUser!.uid,
createdAt: new Date(),
updatedAt: new Date(),
isPublished: false,
isListed: true,
details: {},
tuneFile: path,
});
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
setTuneFile(false);
return { result, message };
}
const valid = (new TuneParser()).parse(await file.arrayBuffer()).isValid();
if (!valid) {
setTuneFile(false);
} else {
setTuneFile(tune);
}
return {
result: valid,
message: 'Tune file is not valid!',
};
});
};
const uploadLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
const uuid = (options.file as UploadFile).uid;
tune[uuid] = path;
const newValues = { ...logFiles, ...tune };
upload(path, options, () => {
updateData(newTuneId!, { logFiles: Object.values(newValues) });
}, 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 LogParser(await file.arrayBuffer());
switch (extension) {
case 'mlg':
valid = parser.isMLG();
break;
case 'msl':
case 'csv':
valid = parser.isMSL();
break;
default:
valid = false;
break;
}
if (valid) {
setLogFiles(newValues);
}
return {
result: valid,
message: 'Log file is empty or not valid!',
};
});
};
const uploadToothLogs = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
const newValues = { ...toothLogFiles, ...tune };
upload(path, options, () => {
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
}, async (file) => {
const { result, message } = await validateSize(file);
if (!result) {
return { result, message };
}
const parser = new TriggerLogsParser(await file.arrayBuffer());
const valid = parser.isComposite() || parser.isTooth();
if (valid) {
setToothLogFiles(newValues);
}
return {
result: valid,
message: 'Tooth logs file is empty or not valid!',
};
});
};
const uploadCustomIni = async (options: UploadRequestOption) => {
const { path } = (options.data as unknown as UploadFileData);
const tune: UploadedFile = {};
tune[(options.file as UploadFile).uid] = path;
upload(path, options, () => {
updateData(newTuneId!, { customIniFile: path });
}, 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 (error) {
valid = false;
validationMessage = (error as Error).message;
}
if (valid) {
setCustomIniFile(tune);
}
return {
result: valid,
message: validationMessage,
};
});
};
const removeTuneFile = async (file: UploadFile) => {
if (tuneFile) {
removeFile(tuneFile[file.uid]);
}
setTuneFile(null);
updateData(newTuneId!, { tuneFile: null });
};
const removeLogFile = async (file: UploadFile) => {
const { uid } = file;
if (logFiles[file.uid]) {
removeFile(logFiles[file.uid]);
}
const newValues = { ...logFiles };
delete newValues[uid];
setLogFiles(newValues);
updateData(newTuneId!, { logFiles: Object.values(newValues) });
};
const removeToothLogFile = async (file: UploadFile) => {
const { uid } = file;
if (toothLogFiles[file.uid]) {
removeFile(toothLogFiles[file.uid]);
}
const newValues = { ...toothLogFiles };
delete newValues[uid];
setToothLogFiles(newValues);
updateData(newTuneId!, { toothLogFiles: Object.values(newValues) });
};
const removeCustomIniFile = async (file: UploadFile) => {
if (customIniFile) {
removeFile(customIniFile![file.uid]);
}
setCustomIniFile(null);
updateData(newTuneId!, { customIniFile: null });
};
const prepareData = useCallback(async () => {
if (!currentUser) {
restrictedPage();
navigate(Routes.LOGIN);
return;
}
try {
await refreshToken();
if (!currentUser.emailVerified) {
emailNotVerified();
navigate(Routes.LOGIN);
return;
}
setIsUserAuthorized(true);
} catch (error) {
Sentry.captureException(error);
console.error(error);
genericError(error as Error);
}
const tuneId = nanoidCustom();
setNewTuneId(tuneId);
console.log('New tuneId:', tuneId);
}, [currentUser, navigate, refreshToken]);
useEffect(() => {
prepareData();
}, [currentUser, prepareData, refreshToken]);
const uploadButton = (
<Space direction="vertical">
<PlusOutlined />Upload
</Space>
);
const shareSection = (
<>
<Divider>Publish & Share</Divider>
{isPublished && <Row>
<Input
style={{ width: `calc(100% - ${hasNavigatorShare ? 65 : 35}px)` }}
value={shareUrl!}
/>
<Tooltip title={copied ? 'Copied!' : 'Copy URL'}>
<Button icon={<CopyOutlined />} onClick={copyToClipboard} />
</Tooltip>
{hasNavigatorShare && (
<Tooltip title="Share">
<Button
icon={<ShareAltOutlined />}
onClick={() => navigator.share({ url: shareUrl! })}
/>
</Tooltip>
)}
</Row>}
<Row style={{ marginTop: 10 }}>
<Item style={{ width: '100%' }}>
{!isPublished ? <Button
type="primary"
block
loading={isLoading}
htmlType="submit"
>
Publish
</Button> : <Button
type="primary"
block
onClick={goToNewTune}
>
Open
</Button>}
</Item>
</Row>
</>
);
const detailsSection = (
<>
<Divider>
<Space>Details</Space>
</Divider>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="make" rules={requiredRules}>
<Input addonBefore="Make"/>
</Item>
</Col>
<Col {...colProps}>
<Item name="model" rules={requiredRules}>
<Input addonBefore="Model"/>
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="year" rules={requiredRules}>
<InputNumber addonBefore="Year" style={{ width: '100%' }} min={1886} max={thisYear} />
</Item>
</Col>
<Col {...colProps}>
<Item name="displacement" rules={requiredRules}>
<InputNumber addonBefore="Displacement" addonAfter="l" min={0} max={100} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="hp">
<InputNumber addonBefore="HP" style={{ width: '100%' }} min={0} />
</Item>
</Col>
<Col {...colProps}>
<Item name="stockHp">
<InputNumber addonBefore="Stock HP" style={{ width: '100%' }} min={0} />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="engineCode">
<Input addonBefore="Engine code"/>
</Item>
</Col>
<Col {...colProps}>
<Item name="cylindersCount">
<InputNumber addonBefore="No of cylinders" style={{ width: '100%' }} min={0} />
</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="turbocharger">Turbocharged</Select.Option>
<Select.Option value="supercharger">Supercharged</Select.Option>
</Select>
</Item>
</Col>
<Col {...colProps}>
<Item name="fuel">
<Input addonBefore="Fuel" />
</Item>
</Col>
</Row>
<Row {...rowProps}>
<Col {...colProps}>
<Item name="injectorsSize">
<InputNumber addonBefore="Injectors size" addonAfter="cc" min={0} />
</Item>
</Col>
<Col {...colProps}>
<Item name="coils">
<Input addonBefore="Coils" />
</Item>
</Col>
</Row>
<Divider style={{ marginTop: 40 }}>
<Space>
README
<Typography.Text type="secondary">(markdown)</Typography.Text>
</Space>
</Divider>
<Tabs defaultActiveKey="source" className="upload-readme">
<Tabs.TabPane tab="Edit" key="source" style={{ height: descriptionEditorHeight }}>
<Item name="readme">
<Input.TextArea
rows={10}
showCount
value={readme}
onChange={(e) => setReadme(e.target.value)}
maxLength={3_000}
/>
</Item>
</Tabs.TabPane>
<Tabs.TabPane tab="Preview" key="preview" style={{ height: descriptionEditorHeight }}>
<div className="markdown-preview">
<ReactMarkdown>
{readme}
</ReactMarkdown>
</div>
</Tabs.TabPane>
</Tabs>
<Divider>
Visibility
</Divider>
<Item name="isListed" label="Listed:" valuePropName="checked">
<Switch />
</Item>
</>
);
const optionalSection = (
<>
<Divider>
<Space>
Upload Logs
<Typography.Text type="secondary">(.mlg, .csv, .msl)</Typography.Text>
</Space>
</Divider>
<Upload
listType="picture-card"
customRequest={uploadLogs}
data={logFileData}
onRemove={removeLogFile}
iconRender={logIcon}
multiple
maxCount={MaxFiles.LOG_FILES}
disabled={isPublished}
onPreview={noop}
accept=".mlg,.csv,.msl"
>
{Object.keys(logFiles).length < MaxFiles.LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
Upload Tooth and Composite logs
<Typography.Text type="secondary">(.csv)</Typography.Text>
</Space>
</Divider>
<Upload
listType="picture-card"
customRequest={uploadToothLogs}
data={toothLogFilesData}
onRemove={removeToothLogFile}
iconRender={toothLogIcon}
multiple
maxCount={MaxFiles.TOOTH_LOG_FILES}
onPreview={noop}
accept=".csv"
>
{Object.keys(toothLogFiles).length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
</Upload>
<Divider>
<Space>
Upload Custom INI
<Typography.Text type="secondary">(.ini)</Typography.Text>
</Space>
</Divider>
<Upload
listType="picture-card"
customRequest={uploadCustomIni}
data={customIniFileData}
onRemove={removeCustomIniFile}
iconRender={iniIcon}
disabled={isPublished}
onPreview={noop}
accept=".ini"
>
{!customIniFile && uploadButton}
</Upload>
{detailsSection}
{shareUrl && tuneFile && shareSection}
</>
);
if (!isUserAuthorized) {
return <Loader />;
}
if (isPublished) {
return (
<div className="small-container">
{shareSection}
</div>
);
}
return (
<div className="small-container">
<Form
onFinish={publish}
initialValues={{
readme: '# My Tune\n\ndescription',
isListed: true,
}}
>
<Divider>
<Space>
Upload Tune
<Typography.Text type="secondary">(.msq)</Typography.Text>
</Space>
</Divider>
<Upload
listType="picture-card"
customRequest={uploadTune}
data={tuneFileData}
onRemove={removeTuneFile}
iconRender={tuneIcon}
disabled={isPublished}
onPreview={noop}
accept=".msq"
>
{tuneFile === null && uploadButton}
</Upload>
{tuneFile && optionalSection}
</Form>
</div>
);
};
export default UploadPage;