diff --git a/src/components/modal/template-modal.tsx b/src/components/modal/template-modal.tsx index 1ddaba9db..f2606c64e 100644 --- a/src/components/modal/template-modal.tsx +++ b/src/components/modal/template-modal.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { translateText } from '../../i18n/config'; import { useRootDispatch } from '../../redux'; import { companyConfig, templateList } from '@railmapgen/rmg-templates-resources'; -import { startLoading } from '../../redux/app/app-slice'; +import { startLoading, stopLoading } from '../../redux/app/app-slice'; import { Events } from '../../constants/constants'; import rmgRuntime from '@railmapgen/rmg-runtime'; import { RmgEnrichedButton } from '@railmapgen/rmg-components'; @@ -41,6 +41,7 @@ export default function TemplateModal(props: TemplateModalProps) { ); onOpenParam(module.default); rmgRuntime.event(Events.OPEN_TEMPLATE, { company, filename }); + dispatch(stopLoading()); }; return ( diff --git a/src/components/page-header/header-actions.tsx b/src/components/page-header/header-actions.tsx index b9e28be50..937378987 100644 --- a/src/components/page-header/header-actions.tsx +++ b/src/components/page-header/header-actions.tsx @@ -1,22 +1,38 @@ import React from 'react'; -import { HStack, Button } from '@chakra-ui/react'; +import { Button, HStack } from '@chakra-ui/react'; import DownloadActions from './download-actions'; -import { MdPalette } from 'react-icons/md'; +import { MdFolder, MdPalette } from 'react-icons/md'; import { useDispatch } from 'react-redux'; import { SidePanelMode } from '../../constants/constants'; -import { setSidePanelMode } from '../../redux/app/app-slice'; +import { setParamConfig, setSidePanelMode } from '../../redux/app/app-slice'; import { useTranslation } from 'react-i18next'; import OpenActions from './open-actions'; +import rmgRuntime, { RmgEnv } from '@railmapgen/rmg-runtime'; +import { useSearchParams } from 'react-router-dom'; export default function HeaderActions() { const { t } = useTranslation(); const dispatch = useDispatch(); + const [, setSearchParams] = useSearchParams(); + + const handleGoToSelectorView = () => { + // reset param config to stop param update trigger + dispatch(setParamConfig(undefined)); + setSearchParams({}); + }; + return ( + {rmgRuntime.getEnv() !== RmgEnv.PRD && ( + + )} + - + {rmgRuntime.getEnv() === RmgEnv.PRD && } + + + + + setIsTemplateModalOpen(false)} + onOpenParam={handleOpenTemplate} + /> ); } diff --git a/src/components/param-selector-view/param-selector.tsx b/src/components/param-selector-view/param-selector.tsx index 660eb6d37..1eaa91bcc 100644 --- a/src/components/param-selector-view/param-selector.tsx +++ b/src/components/param-selector-view/param-selector.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ButtonGroup, Flex, IconButton, SystemStyleObject } from '@chakra-ui/react'; +import { Box, ButtonGroup, Flex, IconButton, SystemStyleObject, Text } from '@chakra-ui/react'; import { getRelativeTime } from '../../util/utils'; import { MdDelete } from 'react-icons/md'; import { useTranslation } from 'react-i18next'; @@ -15,19 +15,22 @@ interface ParamSelectorProps { const styles: SystemStyleObject = { flex: 1, - flexDirection: 'column', - h: 200, - overflowX: 'hidden', - overflowY: 'auto', - borderRadius: 'md', - borderWidth: 2, - '& .chakra-button__group': { - flexShrink: 0, - h: 10, + '& > div': { + flexDirection: 'column', + h: 200, + overflowX: 'hidden', + overflowY: 'auto', + borderRadius: 'md', + borderWidth: 2, - '& button': { - h: '100%', + '& .chakra-button__group': { + flexShrink: 0, + h: 10, + + '& button': { + h: '100%', + }, }, }, }; @@ -37,32 +40,41 @@ export default function ParamSelector(props: ParamSelectorProps) { const { t } = useTranslation(); return ( - - {paramRegistry - .slice() - .sort((a, b) => { - return (b.lastModified ?? 0) - (a.lastModified ?? 0); - }) - .map(({ id, lastModified }) => ( - - onParamSelect(id)} - /> - } - onClick={() => onParamRemove(id)} - /> - - ))} - + + + {paramRegistry + .slice() + .sort((a, b) => { + return (b.lastModified ?? 0) - (a.lastModified ?? 0); + }) + .map(({ id, lastModified }) => ( + + onParamSelect(id)} + /> + } + onClick={() => onParamRemove(id)} + /> + + ))} + + {paramRegistry.length >= 10 && ( + + {t('You have reached the maximum number of projects.')} + + )} + ); } diff --git a/src/components/root/app-router.tsx b/src/components/root/app-router.tsx index 8d4dc12e1..b65da1183 100644 --- a/src/components/root/app-router.tsx +++ b/src/components/root/app-router.tsx @@ -49,6 +49,7 @@ export default function AppRouter() { setSearchParams({ project: Object.keys(paramMap)[0] ?? nanoid() }); } else { console.log('AppRouter:: No URL param ID provided. Rendering param selector view...'); + setIsLoaded(false); } } }, [paramId]); diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index 8524c7ef5..e1265cbde 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -249,6 +249,7 @@ "desc": "降序" }, + "All projects": "所有项目", "Are you sure to remove station? You cannot undo this action.": "您确定刪除此车站吗?您不能撤销此操作。", "Blank project": "空白项目", "Branch left end": "支线左端", @@ -265,12 +266,22 @@ "Contribution Wiki": "贡献者指南", "Contributors": "贡献者", "Core contributors": "核心贡献者", + "day ago": "天前", + "days ago": "天前", "Disconnect from main line": "从主线断开", "Empty template": "空白模板", "English name": "英文名称", "Help and support": "帮助和支持", + "hour ago": "小時前", + "hours ago": "小時前", + "Import project": "导入项目", + "Just now": "刚刚", + "Last modified": "上次编辑", + "minute ago": "分钟前", + "minutes ago": "分钟前", "No branches found": "未找到支线", - "Open project": "打开项目", + "Open selected": "打开选中项目", + "Open template": "打开模板", "Paid area": "付费区换乘", "Please select...": "请选择...", "Project ID": "项目ID", @@ -293,6 +304,7 @@ "View": "查看", "Visit": "访问", "Visit GitHub": "访问GitHub", + "You have reached the maximum number of projects.": "您已到达项目数量上限。", ", open an issue and join us today!": ",创建一个Issue和加入我们!", "CanvasType": { diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 0021c0a13..5c103145f 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -249,6 +249,7 @@ "desc": "降序" }, + "All projects": "所有專案", "Are you sure to remove station? You cannot undo this action.": "確定移除該車站嗎?此動作無法還原。", "Blank project": "空白專案", "Branch left end": "支綫左端", @@ -265,12 +266,22 @@ "Connect to main line": "連接至主綫", "Contributors": "貢獻者", "Core contributors": "核心貢獻者", + "day ago": "日前", + "days ago": "日前", "Disconnect from main line": "从主綫斷開", "Empty template": "空白範本", "English name": "英文名稱", "Help and support": "幫助及支援", + "hour ago": "小时前", + "hours ago": "小时前", + "Import project": "讀入專案", + "Just now": "剛才", + "Last modified": "上次修改", + "minute ago": "分鐘前", + "minutes ago": "分鐘前", "No branches found": "未找到支綫", - "Open project": "開啟專案", + "Open selected": "開啟所選專案", + "Open template": "開啟範本", "Paid area": "付費區轉車", "Please select...": "請選擇...", "Project ID": "專案ID", @@ -293,6 +304,7 @@ "View": "檢視", "Visit": "造訪", "Visit GitHub": "造訪GitHub", + "You have reached the maximum number of projects.": "你已到達專案數量上限。", ", open an issue and join us today!": ",新增一個Issue並且加入我們!", "CanvasType": { diff --git a/src/redux/app/action.ts b/src/redux/app/action.ts index 334269688..51555eae8 100644 --- a/src/redux/app/action.ts +++ b/src/redux/app/action.ts @@ -31,6 +31,7 @@ export const readParam = (paramId: string, language: LanguageCode) => { dispatch(setFullParam(nextParam)); } catch (err) { console.warn('Failed to parse param.', err); + // FIXME: show error preventing from opening app view console.log('Initiating new param for ID=' + paramId); const nextParam = initParam(RmgStyle.MTR, language); diff --git a/src/redux/app/app-slice.ts b/src/redux/app/app-slice.ts index 86908b599..2b391d022 100644 --- a/src/redux/app/app-slice.ts +++ b/src/redux/app/app-slice.ts @@ -38,7 +38,7 @@ const appSlice = createSlice({ name: 'app', initialState, reducers: { - setParamConfig: (state, action: PayloadAction) => { + setParamConfig: (state, action: PayloadAction) => { state.paramConfig = action.payload; }, diff --git a/src/util/param-manager-utils.test.ts b/src/util/param-manager-utils.test.ts index e5db502a3..1fb810199 100644 --- a/src/util/param-manager-utils.test.ts +++ b/src/util/param-manager-utils.test.ts @@ -84,10 +84,14 @@ describe('ParamMgrUtils', () => { JSON.stringify({ lastModified: Date.now() }) ); + // get actual param registry const result = getParamRegistry(); expect(result).toHaveLength(2); expect(result).toContainEqual(expect.objectContaining({ id: 'test-01' })); expect(result).toContainEqual(expect.objectContaining({ id: 'test-03' })); + + // remove invalid param config from localStorage + expect(window.localStorage.getItem(LocalStorageKey.PARAM_CONFIG_BY_ID + 'test-02')).toBeNull(); }); }); }); diff --git a/src/util/param-manager-utils.ts b/src/util/param-manager-utils.ts index 6bc41013a..2fce065b6 100644 --- a/src/util/param-manager-utils.ts +++ b/src/util/param-manager-utils.ts @@ -65,9 +65,10 @@ export const upgradeLegacyParam = () => { }; export const getParamRegistry = (): ParamConfig[] => { + // load all paramConfig from localStorage const loadedParamRegistry = loadParamRegistry(); - // sync paramRegistry with localStorage items + // sync paramRegistry with actual localStorage param items const actualParamRegistry: ParamConfig[] = []; iterateLocalStorage( key => key.startsWith(LocalStorageKey.PARAM_BY_ID), @@ -82,11 +83,16 @@ export const getParamRegistry = (): ParamConfig[] => { } } ); - console.log( 'getParamRegistry():: Actual param found in localStorage', actualParamRegistry.map(config => config.id) ); + + // remove invalid paramConfig from localStorage + loadedParamRegistry + .filter(config => actualParamRegistry.every(c => c.id !== config.id)) + .forEach(config => window.localStorage.removeItem(LocalStorageKey.PARAM_CONFIG_BY_ID + config.id)); + return actualParamRegistry; }; @@ -103,3 +109,13 @@ const iterateLocalStorage = ( count++; } }; + +/** + * @param param Accept any param string and save to localStorage. It will not be validated until opening it. + */ +export const importParam = (param: string): string => { + const id = nanoid(); + window.localStorage.setItem(LocalStorageKey.PARAM_BY_ID + id, param); + window.localStorage.setItem(LocalStorageKey.PARAM_CONFIG_BY_ID + id, JSON.stringify({ lastModified: Date.now() })); + return id; +}; diff --git a/src/util/utils.ts b/src/util/utils.ts index 49bb573c2..9b7111470 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -28,21 +28,25 @@ export const isSafari = () => { return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome'); }; -export const getRelativeTime = (timestamp?: number): string => { +export const getRelativeTime = (timestamp?: number): string[] => { if (timestamp) { const deltaSeconds = new Date().getTime() - timestamp; if (deltaSeconds < 60 * 1000) { - return 'Less than 1 minute ago'; + return ['Just now']; } else if (deltaSeconds < 2 * 60 * 1000) { - return '1 minute ago'; + return ['1', 'minute ago']; } else if (deltaSeconds < 60 * 60 * 1000) { - return Math.floor(deltaSeconds / 1000 / 60).toString() + ' minutes ago'; + return [Math.floor(deltaSeconds / 1000 / 60).toString(), 'minutes ago']; } else if (deltaSeconds < 2 * 60 * 60 * 1000) { - return '1 hour ago'; + return ['1', 'hour ago']; + } else if (deltaSeconds < 24 * 60 * 60 * 1000) { + return [Math.floor(deltaSeconds / 1000 / 60 / 60).toString(), 'hours ago']; + } else if (deltaSeconds < 48 * 60 * 60 * 1000) { + return ['1', 'day ago']; } else { - return Math.floor(deltaSeconds / 1000 / 60 / 60).toString() + ' hours ago'; + return [Math.floor(deltaSeconds / 1000 / 60 / 60 / 24).toString(), 'days ago']; } } else { - return 'Unknown'; + return ['Unknown']; } };