diff --git a/.vscode/settings.json b/.vscode/settings.json index b5b037c..ef292ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "cSpell.words": [ + "hypertuner", "kbar", "pocketbase", "prefs", diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 45826d0..41e0639 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,7 +1,11 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable jsx-a11y/anchor-has-content */ + import { useCallback, useEffect, useMemo, + useRef, } from 'react'; import { useLocation, @@ -31,10 +35,7 @@ import { LoginOutlined, LineChartOutlined, SlidersOutlined, - FileExcelOutlined, - FileTextOutlined, FileZipOutlined, - SaveOutlined, DesktopOutlined, DownOutlined, SearchOutlined, @@ -44,6 +45,8 @@ import { LogoutOutlined, InfoCircleOutlined, CarOutlined, + FileTextOutlined, + FileExcelOutlined, } from '@ant-design/icons'; import { useKBar } from 'kbar'; import store from '../store'; @@ -53,10 +56,17 @@ import { Routes } from '../routes'; import { useAuth } from '../contexts/AuthContext'; import { logOutSuccessful } from '../pages/auth/notifications'; import { TuneDataState } from '../types/state'; +import { removeFilenameSuffix } from '../pocketbase'; +import useServerStorage from '../hooks/useServerStorage'; const { Header } = Layout; const { useBreakpoint } = Grid; -const { SubMenu } = Menu; + +const logsExtensionsIcons: { [key: string]: any } = { + 'mlg': , + 'msl': , + 'csv': , +}; const TopBar = ({ tuneData, @@ -77,6 +87,8 @@ const TopBar = ({ const tabMatch = useMatch(`${Routes.TUNE_TAB}/*`); const uploadMatch = useMatch(Routes.UPLOAD); const hubMatch = useMatch(Routes.HUB); + const { downloadFile } = useServerStorage(); + const downloadAnchorRef = useRef(null); const logoutClick = useCallback(() => { logout(); logOutSuccessful(); @@ -92,6 +104,54 @@ const TopBar = ({ } }, []); + const downloadLogsItems = { + label: 'Logs', + icon: , + key: 'logs', + children: (tuneData?.logFiles || []).map((filename) => ({ + key: filename, + label: removeFilenameSuffix(filename), + icon: logsExtensionsIcons[filename.slice(-3)], + onClick: () => downloadFile(tuneData!.id, filename, downloadAnchorRef.current!), + })), + }; + + const downloadToothLogsItems = { + label: 'Tooth logs', + icon: , + key: 'toothLogs', + children: (tuneData?.toothLogFiles || []).map((filename) => ({ + key: filename, + label: removeFilenameSuffix(filename), + icon: logsExtensionsIcons[filename.slice(-3)], + onClick: () => downloadFile(tuneData!.id, filename, downloadAnchorRef.current!), + })), + }; + + const downloadItems = [ + { + label: 'Tune', + icon: , + key: 'tune', + children: [ + { + label: 'Download', + icon: , + key: 'download', + onClick: () => downloadFile(tuneData!.id, tuneData!.tuneFile, downloadAnchorRef.current!), + }, + { + label: 'Open in app', + icon: , + key: 'open', + onClick: () => window.open(`hypertuner://hypertuner.cloud/t/${tuneData!.tuneId}`, '_blank'), + }, + ], + }, + (tuneData?.logFiles || []).length > 0 ? { ...downloadLogsItems } : null, + (tuneData?.toothLogFiles || []).length > 0 ? { ...downloadToothLogsItems } : null, + ]; + useEffect(() => { document.addEventListener('keydown', handleGlobalKeyboard); @@ -212,31 +272,9 @@ const TopBar = ({ {lg && 'Upload'} - - }> - }> - - Download - - - }> - Open in app - - - }> - }> - MLG - - }> - MSL - - }> - CSV - - - + } placement="bottom" trigger={['click']} @@ -244,7 +282,7 @@ const TopBar = ({ - + } } placement="bottomRight" @@ -254,6 +292,8 @@ const TopBar = ({ {sm && } + {/* dummy anchor for file download */} + diff --git a/src/hooks/useServerStorage.ts b/src/hooks/useServerStorage.ts index 11113be..f9551d7 100644 --- a/src/hooks/useServerStorage.ts +++ b/src/hooks/useServerStorage.ts @@ -1,12 +1,16 @@ import Pako from 'pako'; import * as Sentry from '@sentry/browser'; -import { API_URL } from '../pocketbase'; +import { + API_URL, + removeFilenameSuffix, +} from '../pocketbase'; import { Collections } from '../@types/pocketbase-types'; import useDb from './useDb'; import { fetchWithProgress, OnProgress, } from '../utils/http'; +import { downloading } from '../pages/auth/notifications'; const useServerStorage = () => { const { getIni } = useDb(); @@ -43,10 +47,28 @@ const useServerStorage = () => { signal, ).then((response) => response); + const downloadFile = async (recordId: string, filename: string, anchorRef: HTMLAnchorElement) => { + downloading(); + + const response = await fetch(buildFileUrl(Collections.Tunes, recordId, filename)); + const data = Pako.inflate(new Uint8Array(await response.arrayBuffer())); + const url = window.URL.createObjectURL(new Blob([data])); + + // eslint-disable-next-line no-param-reassign + anchorRef.href = url; + // eslint-disable-next-line no-param-reassign + anchorRef.target = '_blank'; + // eslint-disable-next-line no-param-reassign + anchorRef.download = removeFilenameSuffix(filename); + anchorRef.click(); + window.URL.revokeObjectURL(url); + }; + return { fetchTuneFile: (recordId: string, filename: string): Promise => fetchTuneFile(recordId, filename), fetchINIFile: (signature: string): Promise => fetchINIFile(signature), fetchLogFileWithProgress: (recordId: string, filename: string, onProgress?: OnProgress, signal?: AbortSignal): Promise => fetchLogFileWithProgress(recordId, filename, onProgress, signal), + downloadFile: (recordId: string, filename: string, anchorRef: HTMLAnchorElement): Promise => downloadFile(recordId, filename, anchorRef), }; }; diff --git a/src/pages/auth/notifications.ts b/src/pages/auth/notifications.ts index 117c487..24547e4 100644 --- a/src/pages/auth/notifications.ts +++ b/src/pages/auth/notifications.ts @@ -147,6 +147,12 @@ const signatureNotSupportedWarning = (message: string) => notification.warning({ ...baseOptions, }); +const downloading = () => notification.success({ + message: 'Downloading...', + ...baseOptions, + duration: 1, +}); + export { error, emailNotVerified, @@ -172,4 +178,5 @@ export { iniLoadingError, tuneParsingError, signatureNotSupportedWarning, + downloading, };