Working Upload (#356)
This commit is contained in:
parent
4c19e143ea
commit
9248ceb7a4
|
@ -16,6 +16,7 @@
|
|||
"antd": "^4.18.0",
|
||||
"firebase": "^9.6.1",
|
||||
"mlg-converter": "^0.5.1",
|
||||
"nanoid": "^3.1.30",
|
||||
"pako": "^2.0.4",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
"antd": "^4.18.0",
|
||||
"firebase": "^9.6.1",
|
||||
"mlg-converter": "^0.5.1",
|
||||
"nanoid": "^3.1.30",
|
||||
"pako": "^2.0.4",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
|
|
13
src/App.less
13
src/App.less
|
@ -50,6 +50,10 @@ html, body {
|
|||
// border-top-width: 1px;
|
||||
// border-top-color: @border-color-split;
|
||||
// border-top-style: solid;
|
||||
|
||||
a {
|
||||
color: @text;
|
||||
}
|
||||
}
|
||||
|
||||
.app-content {
|
||||
|
@ -68,6 +72,15 @@ html, body {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.ant-upload-list-picture-card
|
||||
.ant-upload-list-item-actions
|
||||
.anticon-delete,
|
||||
.ant-upload-list-picture-card
|
||||
.ant-upload-list-item-actions
|
||||
.anticon-eye {
|
||||
color: @text;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin: 20px;
|
||||
|
||||
|
|
19
src/App.tsx
19
src/App.tsx
|
@ -26,20 +26,19 @@ import {
|
|||
import Dialog from './components/Dialog';
|
||||
import { loadAll } from './utils/api';
|
||||
import SideBar, { DialogMatchedPathType } from './components/SideBar';
|
||||
import BurnButton from './components/BurnButton';
|
||||
import TopBar from './components/TopBar';
|
||||
import StatusBar from './components/StatusBar';
|
||||
import { isDesktop } from './utils/env';
|
||||
import 'react-perfect-scrollbar/dist/css/styles.css';
|
||||
import './App.less';
|
||||
import { Routes } from './routes';
|
||||
import Log from './components/Log';
|
||||
import Diagnose from './components/Diagnose';
|
||||
import Log from './pages/Log';
|
||||
import Diagnose from './pages/Diagnose';
|
||||
import useStorage from './hooks/useStorage';
|
||||
import useConfig from './hooks/useConfig';
|
||||
import Login from './components/Auth/Login';
|
||||
import SignUp from './components/Auth/SignUp';
|
||||
import ResetPassword from './components/Auth/ResetPassword';
|
||||
import Login from './pages/auth/Login';
|
||||
import SignUp from './pages/auth/SignUp';
|
||||
import ResetPassword from './pages/auth/ResetPassword';
|
||||
import Upload from './pages/Upload';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
|
@ -117,7 +116,6 @@ const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
|
|||
<Dialog
|
||||
name={dialogMatchedPath.params.dialog}
|
||||
url={dialogMatchedPath.url}
|
||||
burnButton={isDesktop && <BurnButton />}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
@ -148,6 +146,11 @@ const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
|
|||
<ResetPassword />
|
||||
</ContentFor>
|
||||
</Route>
|
||||
<Route path={Routes.UPLOAD}>
|
||||
<ContentFor>
|
||||
<Upload />
|
||||
</ContentFor>
|
||||
</Route>
|
||||
</Switch>
|
||||
<Result
|
||||
status="warning"
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Grid,
|
||||
} from 'antd';
|
||||
import { FireOutlined } from '@ant-design/icons';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const BurnButton = () => {
|
||||
const { md } = useBreakpoint();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
danger
|
||||
htmlType="submit"
|
||||
icon={<FireOutlined />}
|
||||
style={{ position: 'fixed', right: 35, bottom: 45 }}
|
||||
>
|
||||
{md && 'Burn'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BurnButton;
|
|
@ -99,14 +99,12 @@ const Dialog = ({
|
|||
tune,
|
||||
url,
|
||||
name,
|
||||
burnButton,
|
||||
}: {
|
||||
ui: UIState,
|
||||
config: ConfigType,
|
||||
tune: TuneType,
|
||||
name: string,
|
||||
url: string,
|
||||
burnButton: any
|
||||
}) => {
|
||||
const isDataReady = Object.keys(tune.constants).length && Object.keys(config.constants).length;
|
||||
const { storageSet } = useStorage();
|
||||
|
@ -411,9 +409,6 @@ const Dialog = ({
|
|||
<Row gutter={20}>
|
||||
{panelsComponents}
|
||||
</Row>
|
||||
<Form.Item>
|
||||
{burnButton}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -34,7 +34,7 @@ const SmartNumber = ({
|
|||
if (isSlider(units || '')) {
|
||||
return (
|
||||
<Slider
|
||||
defaultValue={defaultValue}
|
||||
value={defaultValue}
|
||||
min={min}
|
||||
max={max}
|
||||
step={10**-digits}
|
||||
|
@ -49,7 +49,7 @@ const SmartNumber = ({
|
|||
|
||||
return (
|
||||
<InputNumber
|
||||
defaultValue={defaultValue}
|
||||
value={defaultValue}
|
||||
precision={digits}
|
||||
min={min}
|
||||
max={max}
|
||||
|
|
|
@ -35,7 +35,7 @@ const SmartSelect = ({
|
|||
if (values.length < 3) {
|
||||
return (
|
||||
<Radio.Group
|
||||
defaultValue={values.indexOf(defaultValue)}
|
||||
value={values.indexOf(defaultValue)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
disabled={disabled}
|
||||
|
@ -49,7 +49,7 @@ const SmartSelect = ({
|
|||
|
||||
return (
|
||||
<Select
|
||||
defaultValue={values.indexOf(defaultValue)}
|
||||
value={values.indexOf(defaultValue)}
|
||||
optionFilterProp="label"
|
||||
disabled={disabled}
|
||||
style={{ maxWidth: 250 }}
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from 'antd';
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
FieldTimeOutlined,
|
||||
GithubOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
|
@ -37,10 +37,16 @@ const StatusBar = ({ status, config }: { status: StatusState, config: ConfigStat
|
|||
{config.megaTune && firmware(config.megaTune.signature)}
|
||||
</Col>
|
||||
<Col span={12} style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<FieldTimeOutlined />
|
||||
{status.text}
|
||||
</Space>
|
||||
<a
|
||||
href="https://github.com/speedy-tuner/speedy-tuner-cloud"
|
||||
target="__blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Space>
|
||||
<GithubOutlined />
|
||||
GitHub
|
||||
</Space>
|
||||
</a>
|
||||
</Col>
|
||||
</Row>
|
||||
</Footer>
|
||||
|
|
|
@ -8,7 +8,6 @@ import {
|
|||
Layout,
|
||||
Space,
|
||||
Button,
|
||||
Input,
|
||||
Row,
|
||||
Col,
|
||||
Tooltip,
|
||||
|
@ -20,21 +19,17 @@ import {
|
|||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
ShareAltOutlined,
|
||||
CloudUploadOutlined,
|
||||
CloudDownloadOutlined,
|
||||
SettingOutlined,
|
||||
LoginOutlined,
|
||||
LineChartOutlined,
|
||||
SlidersOutlined,
|
||||
GithubOutlined,
|
||||
FileExcelOutlined,
|
||||
FileTextOutlined,
|
||||
FileZipOutlined,
|
||||
SaveOutlined,
|
||||
DesktopOutlined,
|
||||
GlobalOutlined,
|
||||
LinkOutlined,
|
||||
DownOutlined,
|
||||
SearchOutlined,
|
||||
ToolOutlined,
|
||||
|
@ -59,14 +54,14 @@ import { useAuth } from '../contexts/AuthContext';
|
|||
import {
|
||||
logOutFailed,
|
||||
logOutSuccessful,
|
||||
} from './Auth/notifications';
|
||||
} from '../pages/auth/notifications';
|
||||
|
||||
const { Header } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
const TopBar = () => {
|
||||
const { sm } = useBreakpoint();
|
||||
const { sm, lg } = useBreakpoint();
|
||||
const { pathname } = useLocation();
|
||||
const { currentUser, logout } = useAuth();
|
||||
const history = useHistory();
|
||||
|
@ -81,76 +76,7 @@ const TopBar = () => {
|
|||
}
|
||||
}, [logout]);
|
||||
|
||||
const userMenu = (
|
||||
<Menu>
|
||||
{currentUser ? (
|
||||
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={logoutClick}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item key="login" icon={<LoginOutlined />}>
|
||||
<Link to={Routes.LOGIN}>Login</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="sign-up" icon={<UserAddOutlined />}>
|
||||
<Link to={Routes.SIGN_UP}>Sign Up</Link>
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="gh" icon={<GithubOutlined />}>
|
||||
<a
|
||||
href="https://github.com/speedy-tuner/speedy-tuner-cloud"
|
||||
target="__blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="preferences" disabled icon={<SettingOutlined />}>
|
||||
Preferences
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const shareMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="upload" disabled icon={<CloudUploadOutlined />}>
|
||||
Upload
|
||||
</Menu.Item>
|
||||
<SubMenu key="download-sub" title="Download" icon={<CloudDownloadOutlined />}>
|
||||
<SubMenu key="tune-sub" title="Tune" icon={<SlidersOutlined />}>
|
||||
<Menu.Item key="download" icon={<SaveOutlined />}>
|
||||
<a href="/tunes/202103.msq" target="__blank" rel="noopener noreferrer">
|
||||
Download
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="open" disabled icon={<DesktopOutlined />}>
|
||||
Open in app
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
<SubMenu key="logs-sub" title="Logs" icon={<LineChartOutlined />}>
|
||||
<Menu.Item key="mlg" disabled icon={<FileZipOutlined />}>
|
||||
MLG
|
||||
</Menu.Item>
|
||||
<Menu.Item key="msl" disabled icon={<FileTextOutlined />}>
|
||||
MSL
|
||||
</Menu.Item>
|
||||
<Menu.Item key="csv" disabled icon={<FileExcelOutlined />}>
|
||||
CSV
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
</SubMenu>
|
||||
<Menu.Item key="link" disabled icon={<LinkOutlined />}>
|
||||
Create link
|
||||
</Menu.Item>
|
||||
<Menu.Item key="publish" disabled icon={<GlobalOutlined />}>
|
||||
Publish to Hub
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const searchInput = useRef<Input | null>(null);
|
||||
const searchInput = useRef<HTMLElement | null>(null);
|
||||
const handleGlobalKeyboard = (e: KeyboardEvent) => {
|
||||
if (isCommand(e)) {
|
||||
if (searchInput) {
|
||||
|
@ -204,25 +130,76 @@ const TopBar = () => {
|
|||
</Col>
|
||||
<Col span={12} md={8} sm={8} style={{ textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Tooltip title={
|
||||
<Tooltip visible={false} title={
|
||||
<>
|
||||
<Typography.Text keyboard>{isMac ? '⌘' : 'CTRL'}</Typography.Text>
|
||||
<Typography.Text keyboard>SHIFT</Typography.Text>
|
||||
<Typography.Text keyboard>P</Typography.Text>
|
||||
</>
|
||||
}>
|
||||
<Button icon={<SearchOutlined />} ref={searchInput as any} />
|
||||
<Button disabled icon={<SearchOutlined />} ref={searchInput} />
|
||||
</Tooltip>
|
||||
<Link to={Routes.UPLOAD}>
|
||||
<Button icon={<CloudUploadOutlined />}>
|
||||
{lg && 'Upload'}
|
||||
</Button>
|
||||
</Link>
|
||||
<Dropdown
|
||||
overlay={shareMenu}
|
||||
overlay={
|
||||
<Menu>
|
||||
<SubMenu key="tune-sub" title="Tune" icon={<SlidersOutlined />}>
|
||||
<Menu.Item key="download" icon={<SaveOutlined />}>
|
||||
<a href="/tunes/202103.msq" target="__blank" rel="noopener noreferrer">
|
||||
Download
|
||||
</a>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="open" disabled icon={<DesktopOutlined />}>
|
||||
Open in app
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
<SubMenu key="logs-sub" title="Logs" icon={<LineChartOutlined />}>
|
||||
<Menu.Item key="mlg" disabled icon={<FileZipOutlined />}>
|
||||
MLG
|
||||
</Menu.Item>
|
||||
<Menu.Item key="msl" disabled icon={<FileTextOutlined />}>
|
||||
MSL
|
||||
</Menu.Item>
|
||||
<Menu.Item key="csv" disabled icon={<FileExcelOutlined />}>
|
||||
CSV
|
||||
</Menu.Item>
|
||||
</SubMenu>
|
||||
</Menu>
|
||||
}
|
||||
placement="bottomCenter"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button icon={<ShareAltOutlined />}>
|
||||
<Button icon={<CloudDownloadOutlined />}>
|
||||
{lg && 'Download'}
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
overlay={userMenu}
|
||||
overlay={
|
||||
<Menu>
|
||||
{currentUser ? (
|
||||
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={logoutClick}>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item key="login" icon={<LoginOutlined />}>
|
||||
<Link to={Routes.LOGIN}>Login</Link>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="sign-up" icon={<UserAddOutlined />}>
|
||||
<Link to={Routes.SIGN_UP}>Sign Up</Link>
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
<Menu.Item key="preferences" disabled icon={<SettingOutlined />}>
|
||||
Preferences
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
}
|
||||
placement="bottomCenter"
|
||||
trigger={['click']}
|
||||
>
|
||||
|
|
|
@ -30,6 +30,7 @@ interface AuthValue {
|
|||
resetPassword: (email: string) => Promise<void>,
|
||||
googleAuth: () => Promise<void>,
|
||||
githubAuth: () => Promise<void>,
|
||||
refreshToken: () => Promise<string> | undefined,
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthValue | null>(null);
|
||||
|
@ -58,6 +59,7 @@ const AuthProvider = (props: { children: ReactNode }) => {
|
|||
const credentials = await signInWithPopup(auth, provider);
|
||||
setCurrentUser(credentials.user);
|
||||
},
|
||||
refreshToken: () => auth.currentUser?.getIdToken(true),
|
||||
}), [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -12,6 +12,21 @@ import {
|
|||
signInWithPopup,
|
||||
} from 'firebase/auth';
|
||||
import { getAnalytics } from 'firebase/analytics';
|
||||
import {
|
||||
getStorage,
|
||||
ref,
|
||||
uploadBytes,
|
||||
uploadBytesResumable,
|
||||
deleteObject,
|
||||
} from 'firebase/storage';
|
||||
import {
|
||||
getFirestore,
|
||||
doc,
|
||||
setDoc,
|
||||
collection,
|
||||
addDoc,
|
||||
getDoc,
|
||||
} from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||
|
@ -27,6 +42,8 @@ const app = initializeApp(firebaseConfig);
|
|||
const analytics = getAnalytics(app);
|
||||
const performance = getPerformance(app);
|
||||
const auth = getAuth(app);
|
||||
const db = getFirestore();
|
||||
const storage = getStorage();
|
||||
|
||||
export {
|
||||
auth,
|
||||
|
@ -40,4 +57,15 @@ export {
|
|||
GoogleAuthProvider,
|
||||
GithubAuthProvider,
|
||||
signInWithPopup,
|
||||
ref as storageRef,
|
||||
storage,
|
||||
uploadBytes,
|
||||
uploadBytesResumable,
|
||||
deleteObject,
|
||||
doc as fireStoreDoc,
|
||||
collection as fireStoreCollection,
|
||||
setDoc,
|
||||
addDoc,
|
||||
getDoc,
|
||||
db,
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ const useStorage = () => {
|
|||
storageGet: (key: string) => storage.get(key),
|
||||
storageGetSync: (key: string) => storage.getSync(key),
|
||||
storageSet: (key: string, value: string) => storage.set(key, value),
|
||||
storageDelete: (key: string) => storage.delete(key),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -35,12 +35,12 @@ import {
|
|||
} from '../utils/api';
|
||||
import store from '../store';
|
||||
import { formatBytes } from '../utils/number';
|
||||
import CompositeCanvas from './TriggerLog/CompositeCanvas';
|
||||
import CompositeCanvas from '../components/TriggerLog/CompositeCanvas';
|
||||
import TriggerLogsParser, {
|
||||
CompositeLogEntry,
|
||||
ToothLogEntry,
|
||||
} from '../utils/logs/TriggerLogsParser';
|
||||
import ToothCanvas from './TriggerLog/ToothCanvas';
|
||||
import ToothCanvas from '../components/TriggerLog/ToothCanvas';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
const { Content } = Layout;
|
|
@ -38,7 +38,7 @@ import {
|
|||
Tune as TuneType,
|
||||
} from '@speedy-tuner/types';
|
||||
import { loadLogs } from '../utils/api';
|
||||
import LogCanvas from './Log/LogCanvas';
|
||||
import LogCanvas from '../components/Log/LogCanvas';
|
||||
import store from '../store';
|
||||
import {
|
||||
formatBytes,
|
|
@ -0,0 +1,508 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Input,
|
||||
notification,
|
||||
Skeleton,
|
||||
Space,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ToolOutlined,
|
||||
FundOutlined,
|
||||
SettingOutlined,
|
||||
CopyOutlined,
|
||||
ShareAltOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
import { UploadFile } from 'antd/lib/upload/interface';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import pako from 'pako';
|
||||
import {
|
||||
customAlphabet,
|
||||
nanoid,
|
||||
} from 'nanoid';
|
||||
import {
|
||||
emailNotVerified,
|
||||
restrictedPage,
|
||||
} from './auth/notifications';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Routes } from '../routes';
|
||||
import {
|
||||
fireStoreDoc,
|
||||
setDoc,
|
||||
getDoc,
|
||||
storage,
|
||||
storageRef,
|
||||
uploadBytesResumable,
|
||||
deleteObject,
|
||||
db,
|
||||
} from '../firebase';
|
||||
import useStorage from '../hooks/useStorage';
|
||||
|
||||
enum MaxFiles {
|
||||
TUNE_FILES = 1,
|
||||
LOG_FILES = 5,
|
||||
TOOTH_LOG_FILES = 5,
|
||||
CUSTOM_INI_FILES = 1,
|
||||
}
|
||||
|
||||
interface TuneDbData {
|
||||
userUid?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
isPublished?: boolean;
|
||||
isListed?: boolean;
|
||||
isPublic?: boolean;
|
||||
tuneFile?: string | null;
|
||||
logFiles?: string[];
|
||||
toothLogFiles?: string[];
|
||||
customIniFile?: string | null;
|
||||
}
|
||||
|
||||
type Path = string;
|
||||
|
||||
interface UploadedFile {
|
||||
[autoUid: string]: Path;
|
||||
}
|
||||
|
||||
interface UploadFileData {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const containerStyle = {
|
||||
padding: 20,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
const NEW_TUNE_ID_KEY = 'newTuneId';
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
|
||||
const tuneIcon = () => <ToolOutlined />;
|
||||
const logIcon = () => <FundOutlined />;
|
||||
const toothLogIcon = () => <SettingOutlined />;
|
||||
const iniIcon = () => <FileTextOutlined />;
|
||||
|
||||
const nanoidCustom = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
|
||||
const baseUploadPath = 'public/users';
|
||||
|
||||
const UploadPage = () => {
|
||||
const [newTuneId, setNewTuneId] = useState<string>();
|
||||
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
|
||||
const hasNavigatorShare = navigator.share !== undefined;
|
||||
const [shareUrl, setShareUrl] = useState<string>();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [isListed, setIsListed] = useState(true);
|
||||
const { currentUser, refreshToken } = useAuth();
|
||||
const history = useHistory();
|
||||
const { storageSet, storageGet, storageDelete } = useStorage();
|
||||
const [tuneFile, setTuneFile] = useState<UploadedFile | null>(null);
|
||||
const [logFiles, setLogFiles] = useState<UploadedFile>({});
|
||||
const [toothLogFiles, setToothLogFiles] = useState<UploadedFile>({});
|
||||
const [customIniFile, setCustomIniFile] = useState<UploadedFile | null>(null);
|
||||
|
||||
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 updateDbData = (tuneId: string, dbData: TuneDbData) => {
|
||||
try {
|
||||
return setDoc(fireStoreDoc(db, 'tunes', tuneId), dbData, { merge: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getDbData = (tuneId: string) => {
|
||||
try {
|
||||
return getDoc(fireStoreDoc(db, 'tunes', tuneId));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
try {
|
||||
return deleteObject(storageRef(storage, path));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
setIsLoading(true);
|
||||
await updateDbData(newTuneId!, {
|
||||
updatedAt: new Date(),
|
||||
isPublished: true,
|
||||
isPublic,
|
||||
isListed,
|
||||
});
|
||||
setIsPublished(true);
|
||||
setIsLoading(false);
|
||||
storageDelete(NEW_TUNE_ID_KEY);
|
||||
};
|
||||
|
||||
const upload = async (path: string, options: UploadRequestOption, done?: Function) => {
|
||||
const { onError, onSuccess, onProgress, file } = options;
|
||||
|
||||
if ((file as File).size / 1024 / 1024 > MAX_FILE_SIZE_MB) {
|
||||
const errorName = 'File too large';
|
||||
const errorMessage = `File should not be larger than ${MAX_FILE_SIZE_MB}MB!`;
|
||||
notification.error({ message: errorName, description: errorMessage });
|
||||
onError!({ name: errorName, message: errorMessage });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await (file as File).arrayBuffer();
|
||||
const compressed = pako.deflate(new Uint8Array(buffer));
|
||||
const uploadTask = uploadBytesResumable(storageRef(storage, path), compressed, {
|
||||
customMetadata: {
|
||||
name: (file as File).name,
|
||||
size: `${(file as File).size}`,
|
||||
},
|
||||
});
|
||||
|
||||
uploadTask.on(
|
||||
'state_changed',
|
||||
(snap) => onProgress!({ percent: (snap.bytesTransferred / snap.totalBytes) * 100 }),
|
||||
(err) => onError!(err),
|
||||
() => {
|
||||
onSuccess!(file);
|
||||
if (done) done();
|
||||
},
|
||||
);
|
||||
} catch (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: `${baseUploadPath}/${currentUser!.uid}/tunes/${newTuneId}/${nanoid()}.msq.gz`,
|
||||
});
|
||||
|
||||
const logFileData = (file: UploadFile) => {
|
||||
const { name } = file;
|
||||
const extension = name.split('.').pop();
|
||||
return {
|
||||
path: `${baseUploadPath}/${currentUser!.uid}/tunes/${newTuneId}/logs/${nanoid()}.${extension}.gz`,
|
||||
};
|
||||
};
|
||||
|
||||
const toothLogFilesData = () => ({
|
||||
path: `${baseUploadPath}/${currentUser!.uid}/tunes/${newTuneId}/tooth-logs/${nanoid()}.csv.gz`,
|
||||
});
|
||||
|
||||
const customIniFileData = () => ({
|
||||
path: `${baseUploadPath}/${currentUser!.uid}/tunes/${newTuneId}/${nanoid()}.ini.gz`,
|
||||
});
|
||||
|
||||
const uploadTune = async (options: UploadRequestOption) => {
|
||||
const found = await getDbData(newTuneId!);
|
||||
if (!found.exists()) {
|
||||
const tuneData: TuneDbData = {
|
||||
userUid: currentUser!.uid,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isPublished: false,
|
||||
isPublic,
|
||||
isListed,
|
||||
tuneFile: null,
|
||||
logFiles: [],
|
||||
toothLogFiles: [],
|
||||
customIniFile: null,
|
||||
};
|
||||
await updateDbData(newTuneId!, tuneData);
|
||||
}
|
||||
setShareUrl(`https://speedytuner.cloud/#/t/${newTuneId}`);
|
||||
|
||||
const { path } = (options.data as unknown as UploadFileData);
|
||||
upload(path, options, () => {
|
||||
const tune: UploadedFile = {};
|
||||
tune[(options.file as UploadFile).uid] = path;
|
||||
setTuneFile(tune);
|
||||
updateDbData(newTuneId!, { tuneFile: path });
|
||||
});
|
||||
};
|
||||
|
||||
const uploadLogs = async (options: UploadRequestOption) => {
|
||||
const { path } = (options.data as unknown as UploadFileData);
|
||||
upload(path, options, async () => {
|
||||
const tune: UploadedFile = {};
|
||||
tune[(options.file as UploadFile).uid] = path;
|
||||
const newValues = { ...logFiles, ...tune };
|
||||
await updateDbData(newTuneId!, { logFiles: Object.values(newValues) });
|
||||
setLogFiles(newValues);
|
||||
});
|
||||
};
|
||||
|
||||
const uploadToothLogs = async (options: UploadRequestOption) => {
|
||||
const { path } = (options.data as unknown as UploadFileData);
|
||||
upload(path, options, async () => {
|
||||
const tune: UploadedFile = {};
|
||||
tune[(options.file as UploadFile).uid] = path;
|
||||
const newValues = { ...toothLogFiles, ...tune };
|
||||
await updateDbData(newTuneId!, { toothLogFiles: Object.values(newValues) });
|
||||
setToothLogFiles(newValues);
|
||||
});
|
||||
};
|
||||
|
||||
const uploadCustomIni = async (options: UploadRequestOption) => {
|
||||
const { path } = (options.data as unknown as UploadFileData);
|
||||
upload(path, options, () => {
|
||||
const tune: UploadedFile = {};
|
||||
tune[(options.file as UploadFile).uid] = path;
|
||||
setCustomIniFile(tune);
|
||||
updateDbData(newTuneId!, { customIniFile: path });
|
||||
});
|
||||
};
|
||||
|
||||
const removeTuneFile = async (file: UploadFile) => {
|
||||
removeFile(tuneFile![file.uid]);
|
||||
updateDbData(newTuneId!, { tuneFile: null });
|
||||
setTuneFile(null);
|
||||
};
|
||||
|
||||
const removeLogFile = async (file: UploadFile) => {
|
||||
const { uid } = file;
|
||||
removeFile(logFiles[file.uid]);
|
||||
const newValues = { ...logFiles };
|
||||
delete newValues[uid];
|
||||
updateDbData(newTuneId!, { logFiles: Object.values(newValues) });
|
||||
setLogFiles(newValues);
|
||||
};
|
||||
|
||||
const removeToothLogFile = async (file: UploadFile) => {
|
||||
const { uid } = file;
|
||||
removeFile(toothLogFiles[file.uid]);
|
||||
const newValues = { ...toothLogFiles };
|
||||
delete newValues[uid];
|
||||
updateDbData(newTuneId!, { toothLogFiles: Object.values(newValues) });
|
||||
setToothLogFiles(newValues);
|
||||
};
|
||||
|
||||
const removeCustomIniFile = async (file: UploadFile) => {
|
||||
removeFile(customIniFile![file.uid]);
|
||||
updateDbData(newTuneId!, { customIniFile: null });
|
||||
setCustomIniFile(null);
|
||||
};
|
||||
|
||||
const prepareData = useCallback(async () => {
|
||||
if (!currentUser) {
|
||||
restrictedPage();
|
||||
history.push(Routes.LOGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshToken();
|
||||
if (!currentUser.emailVerified) {
|
||||
emailNotVerified();
|
||||
history.push(Routes.LOGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsUserAuthorized(true);
|
||||
} catch (error) {
|
||||
storageDelete(NEW_TUNE_ID_KEY);
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
}
|
||||
|
||||
let newTuneIdTemp = await storageGet(NEW_TUNE_ID_KEY);
|
||||
if (!newTuneIdTemp) {
|
||||
newTuneIdTemp = nanoidCustom();
|
||||
await storageSet(NEW_TUNE_ID_KEY, newTuneIdTemp);
|
||||
}
|
||||
setNewTuneId(newTuneIdTemp);
|
||||
}, [currentUser, history, refreshToken, storageDelete, storageGet, storageSet]);
|
||||
|
||||
useEffect(() => {
|
||||
prepareData();
|
||||
}, [currentUser, history, prepareData, refreshToken]);
|
||||
|
||||
const uploadButton = (
|
||||
<Space direction="vertical">
|
||||
<PlusOutlined />Upload
|
||||
</Space>
|
||||
);
|
||||
|
||||
const shareSection = (
|
||||
<>
|
||||
<Divider>Publish & Share</Divider>
|
||||
<Input
|
||||
style={{ width: `calc(100% - ${hasNavigatorShare ? 160 : 128}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>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ float: 'right' }}
|
||||
disabled={isPublished || isLoading}
|
||||
onClick={publish}
|
||||
>
|
||||
{isPublished && !isLoading ? 'Published' : 'Publish'}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
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}
|
||||
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}
|
||||
accept=".csv"
|
||||
>
|
||||
{Object.keys(toothLogFiles).length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
|
||||
</Upload>
|
||||
<Space style={{ marginTop: 30 }}>
|
||||
Show more:
|
||||
<Switch checked={showOptions} onChange={setShowOptions} />
|
||||
</Space>
|
||||
{showOptions && <>
|
||||
<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}
|
||||
accept=".ini"
|
||||
>
|
||||
{!customIniFile && uploadButton}
|
||||
</Upload>
|
||||
<Divider>
|
||||
Visibility
|
||||
</Divider>
|
||||
<Space direction="vertical" size="large">
|
||||
<Space>
|
||||
Public:<Switch disabled checked={isPublic} onChange={setIsPublic} />
|
||||
</Space>
|
||||
<Space>
|
||||
Listed:<Switch checked={isListed} onChange={setIsListed} />
|
||||
</Space>
|
||||
</Space>
|
||||
</>}
|
||||
{shareUrl && tuneFile && shareSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<Skeleton active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{shareSection}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<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}
|
||||
accept=".msq"
|
||||
>
|
||||
{!tuneFile && uploadButton}
|
||||
</Upload>
|
||||
{tuneFile && optionalSection}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
|
@ -11,4 +11,5 @@ export enum Routes {
|
|||
SIGN_UP = '/auth/sign-up',
|
||||
FORGOT_PASSWORD = '/auth/forgot-password',
|
||||
RESET_PASSWORD = '/auth/reset-password',
|
||||
UPLOAD = '/upload',
|
||||
}
|
||||
|
|
|
@ -8,6 +8,6 @@ enum Keys {
|
|||
ESCAPE = 'Escape',
|
||||
}
|
||||
|
||||
export const isCommand = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.key === Keys.COMMAND;
|
||||
export const isCommand = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.shiftKey && e.key === Keys.COMMAND;
|
||||
export const isToggleSidebar = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.key === Keys.SIDEBAR;
|
||||
export const isEscape = (e: KeyEvent) => e.key === Keys.ESCAPE;
|
||||
|
|
|
@ -19,6 +19,10 @@ class Storage {
|
|||
public set(key: string, value: string) {
|
||||
return this.storage.set(key, value);
|
||||
}
|
||||
|
||||
public delete(key: string) {
|
||||
return this.storage.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export default Storage;
|
||||
|
|
|
@ -21,6 +21,10 @@ class BrowserStorage implements StorageInterface {
|
|||
this.storage.setItem(key, value);
|
||||
}
|
||||
|
||||
public delete(key: string): void {
|
||||
return this.storage.removeItem(key);
|
||||
}
|
||||
|
||||
public async isAvailable(): Promise<boolean> {
|
||||
return !!this.storage;
|
||||
}
|
||||
|
|
|
@ -2,5 +2,6 @@ export interface StorageInterface {
|
|||
get(key: string): Promise<string | null>;
|
||||
getSync(key: string): string | null;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
delete(key: string): void;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue