diff --git a/src/components/workspace/welcome.test.tsx b/src/components/workspace/welcome.test.tsx new file mode 100644 index 0000000..672ecec --- /dev/null +++ b/src/components/workspace/welcome.test.tsx @@ -0,0 +1,59 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { createTestStore, render } from '../../test-utils'; +import Welcome from './welcome'; + +const mockMatchMedia = (isMobilePortrait: boolean) => { + window.matchMedia = vi.fn().mockImplementation(query => ({ + media: query, + matches: query === '(max-width: 48em) and (orientation: portrait)' ? isMobilePortrait : false, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +}; + +describe('Welcome', () => { + const rmaDescription = + 'Generate station announcement content for a route. Good for turning station, transfer, and direction information into announcement copy that feels closer to a real ride experience.'; + + beforeEach(() => { + mockMatchMedia(false); + }); + + it('renders primary app cards', () => { + render(, { store: createTestStore() }); + + expect(screen.getByText('Rail Map Generator')).toBeInTheDocument(); + expect(screen.getByText('Rail Map Painter')).toBeInTheDocument(); + expect(screen.getByText('Rail Map Announcer')).toBeInTheDocument(); + expect(screen.getByText('Rail Sign Generator')).toBeInTheDocument(); + }); + + it('opens selected app from app card', () => { + const store = createTestStore(); + render(, { store }); + + fireEvent.click(screen.getByRole('button', { name: 'Open Rail Map Announcer' })); + + expect(store.getState().app.openedTabs).toEqual([expect.objectContaining({ app: 'rma', url: '/rma/' })]); + }); + + it('expands mobile app details without opening the app', () => { + mockMatchMedia(true); + const store = createTestStore(); + render(, { store }); + + expect(screen.queryByText(rmaDescription)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Expand Rail Map Announcer' })); + + expect(store.getState().app.openedTabs).toHaveLength(0); + expect(screen.getByText(rmaDescription)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Open Rail Map Announcer' })); + + expect(store.getState().app.openedTabs).toEqual([expect.objectContaining({ app: 'rma', url: '/rma/' })]); + }); +}); diff --git a/src/components/workspace/welcome.tsx b/src/components/workspace/welcome.tsx index 23a9129..4b992e0 100644 --- a/src/components/workspace/welcome.tsx +++ b/src/components/workspace/welcome.tsx @@ -1,30 +1,207 @@ import classes from './workspace.module.css'; import { useTranslation } from 'react-i18next'; import rmpLogo from '../../images/rmp-logo512.png'; -import { Group, Image, Stack, Text, Title } from '@mantine/core'; +import { Image, Stack, Text, Title } from '@mantine/core'; import { useRootDispatch } from '../../redux'; import { openApp } from '../../redux/app/app-slice'; +import { assetEnablement } from '../../util/asset-enablements'; +import { MdChevronRight, MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md'; +import { useMediaQuery } from '@mantine/hooks'; +import { type KeyboardEvent, type MouseEvent, useState } from 'react'; + +type WelcomeAppId = 'rmg' | 'rmp' | 'rma' | 'rsg'; + +interface WelcomeApp { + appId: WelcomeAppId; + descriptionKey: string; + logoSrc?: string; + logoText?: string; +} + +const welcomeApps: WelcomeApp[] = [ + { + appId: 'rmg', + logoSrc: import.meta.env.BASE_URL + 'logo512.png', + descriptionKey: 'WelcomePage.rmg', + }, + { + appId: 'rmp', + logoSrc: rmpLogo, + descriptionKey: 'WelcomePage.rmp', + }, + { + appId: 'rma', + logoText: 'RMA', + descriptionKey: 'WelcomePage.rma', + }, + { + appId: 'rsg', + logoText: 'RSG', + descriptionKey: 'WelcomePage.rsg', + }, +]; + +interface WelcomeAppCardProps { + app: WelcomeApp; + appName: string; + onOpenApp: (appId: WelcomeAppId) => void; + onOpenAppKeyDown: (event: KeyboardEvent, appId: WelcomeAppId) => void; +} + +const AppIcon = ({ app }: { app: WelcomeApp }) => ( +
+ {app.logoSrc ? ( + + ) : ( + {app.logoText} + )} +
+); + +const AppDescriptions = ({ app }: { app: WelcomeApp }) => { + const { t } = useTranslation(); + + return {t(app.descriptionKey)}; +}; + +const DesktopWelcomeAppCard = (props: WelcomeAppCardProps) => { + const { t } = useTranslation(); + const { app, appName, onOpenApp, onOpenAppKeyDown } = props; + + return ( +
onOpenApp(app.appId)} + onKeyDown={event => onOpenAppKeyDown(event, app.appId)} + > + + + + {appName} + + + +
+ +
+
+ ); +}; + +const MobileWelcomeAppCard = (props: WelcomeAppCardProps) => { + const { t } = useTranslation(); + const { app, appName, onOpenApp, onOpenAppKeyDown } = props; + const [isExpanded, setIsExpanded] = useState(false); + + const handleExpandClick = (event: MouseEvent) => { + event.stopPropagation(); + setIsExpanded(value => !value); + }; + + const handleExpandKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + }; + + return ( +
onOpenApp(app.appId)} + onKeyDown={event => onOpenAppKeyDown(event, app.appId)} + > +
+ + + {appName} + +
+ +
+
+ + {isExpanded && ( +
+ + + +
+ )} + + +
+ ); +}; export default function Welcome() { const { t } = useTranslation(); const dispatch = useRootDispatch(); + const isMobilePortrait = useMediaQuery('(max-width: 48em) and (orientation: portrait)', false, { + getInitialValueInEffect: false, + }); + + const handleOpenApp = (appId: WelcomeAppId) => { + dispatch(openApp({ appId })); + }; + + const handleOpenAppKeyDown = (event: KeyboardEvent, appId: WelcomeAppId) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleOpenApp(appId); + } + }; return ( - - - dispatch(openApp({ appId: 'rmg' }))} - /> - dispatch(openApp({ appId: 'rmp' }))} - /> - - {t('Welcome to Rail Map Toolkit')} - {t('Select an app to start your own rail map design!')} - +
+ + + {t('Welcome to Rail Map Toolkit')} + {t('WelcomePage.subtitle')} + + + + {welcomeApps.map(app => { + const appName = assetEnablement[app.appId].name + .split(' - ') + .map(name => t(name)) + .join(' - '); + + return isMobilePortrait ? ( + + ) : ( + + ); + })} + + +
); } diff --git a/src/components/workspace/workspace.module.css b/src/components/workspace/workspace.module.css index 9412d3e..74105a7 100644 --- a/src/components/workspace/workspace.module.css +++ b/src/components/workspace/workspace.module.css @@ -27,29 +27,279 @@ .welcome { height: 100%; - align-items: center; - justify-content: center; - overflow: hidden; + overflow: auto; + padding: calc(env(safe-area-inset-top) + 56px) var(--mantine-spacing-xl) + calc(env(safe-area-inset-bottom) + var(--mantine-spacing-xl)); +} + +.welcome-content { + width: min(75%, 1120px); + min-width: min(720px, 100%); + margin: 0 auto; + align-items: stretch; + gap: var(--mantine-spacing-xl); +} - h1, p { - text-align: center; +.welcome-heading { + align-items: flex-start; + + h1 { + font-size: 2.125rem; + line-height: 1.15; + letter-spacing: 0; } p { + max-width: 620px; + color: var(--mantine-color-dimmed); font-size: var(--mantine-font-size-lg); + line-height: 1.45; } } -.welcome .icons { - margin-bottom: var(--mantine-spacing-xs); +.welcome-app-list { + gap: var(--mantine-spacing-md); +} + +.welcome-app-card { + display: grid; + grid-template-columns: 112px minmax(0, 1fr) 96px; + align-items: center; + min-height: 128px; + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); + border-left: 5px solid var(--mantine-primary-color-filled); + border-radius: 8px; + box-shadow: var(--mantine-shadow-xs); + cursor: pointer; + overflow: hidden; + transition: + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease; - img { - width: 120px; - padding: var(--mantine-spacing-xs); - background: white; - border: 1px solid var(--mantine-color-default-border); - border-radius: 12px; + @mixin hover { + background: var(--mantine-primary-color-light); + border-color: color-mix( + in srgb, + var(--mantine-primary-color-filled) 45%, + var(--mantine-color-default-border) + ); box-shadow: var(--mantine-shadow-sm); - cursor: pointer; } -} \ No newline at end of file + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + } +} + +.welcome-app-icon { + width: 76px; + height: 76px; + justify-self: start; + margin-left: var(--mantine-spacing-lg); + display: flex; + align-items: center; + justify-content: center; + background: var(--mantine-primary-color-light); + border: 1px solid + color-mix(in srgb, var(--mantine-primary-color-filled) 28%, var(--mantine-color-default-border)); + border-radius: 8px; +} + +.welcome-app-logo { + width: 56px; + height: 56px; + object-fit: contain; +} + +.welcome-app-initials { + color: var(--mantine-primary-color-filled); + font-size: var(--mantine-font-size-lg); + font-weight: 700; + letter-spacing: 0; +} + +.welcome-app-copy { + min-width: 0; + padding: var(--mantine-spacing-lg) var(--mantine-spacing-lg) var(--mantine-spacing-lg) 0; + + h2 { + font-size: 1.3rem; + line-height: 1.2; + letter-spacing: 0; + } +} + +.welcome-app-description { + color: var(--mantine-color-dimmed); + font-size: var(--mantine-font-size-sm); + line-height: 1.45; +} + +.welcome-app-open { + width: 96px; + height: 100%; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-primary-color-filled); + background: transparent; + + svg { + width: 36px; + height: 36px; + } +} + +.welcome-app-card.welcome-app-card-mobile { + display: block; + min-height: 0; +} + +.welcome-app-mobile-summary { + min-height: 78px; + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 64px; + align-items: center; + + h2 { + min-width: 0; + padding-right: var(--mantine-spacing-md); + font-size: var(--mantine-font-size-lg); + line-height: 1.2; + letter-spacing: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .welcome-app-icon { + width: 52px; + height: 52px; + margin-left: var(--mantine-spacing-md); + } + + .welcome-app-logo { + width: 38px; + height: 38px; + } + + .welcome-app-open { + width: 64px; + } +} + +.welcome-app-mobile-detail { + min-height: 78px; + border-top: 1px solid + color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border)); + + .welcome-app-copy { + padding: var(--mantine-spacing-sm) var(--mantine-spacing-md); + } +} + +.welcome-app-expand { + width: 100%; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-primary-color-filled); + background: var(--mantine-primary-color-light); + border: 0; + border-top: 1px solid + color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border)); + cursor: pointer; + + @mixin hover { + background: var(--mantine-primary-color-light-hover); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } + + svg { + width: 18px; + height: 18px; + } +} + +@media (max-width: 48em) { + .welcome { + padding-right: var(--mantine-spacing-md); + padding-left: var(--mantine-spacing-md); + } + + .welcome-content { + width: min(100%, 720px); + min-width: 0; + gap: var(--mantine-spacing-lg); + } + + .welcome-heading { + h1 { + font-size: 1.75rem; + } + + p { + font-size: var(--mantine-font-size-md); + } + } + + .welcome-app-card { + grid-template-columns: 76px minmax(0, 1fr) 84px; + min-height: 112px; + } + + .welcome-app-card.welcome-app-card-mobile { + min-height: 0; + } + + .welcome-app-icon { + width: 56px; + height: 56px; + margin-left: var(--mantine-spacing-md); + } + + .welcome-app-logo { + width: 42px; + height: 42px; + } + + .welcome-app-copy h2 { + font-size: var(--mantine-font-size-lg); + } + + .welcome-app-copy { + padding: var(--mantine-spacing-md) var(--mantine-spacing-md) var(--mantine-spacing-md) 0; + } + + .welcome-app-open { + width: 84px; + + svg { + width: 32px; + height: 32px; + } + } +} + +@media (max-width: 36em) { + .welcome-app-card { + grid-template-columns: 64px minmax(0, 1fr) 64px; + } + + .welcome-app-open { + width: 64px; + } + + .welcome-app-description { + font-size: var(--mantine-font-size-xs); + } +} diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 81a1e5a..6c98742 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -23,6 +23,16 @@ "text4": "Please close this modal unless you're certain to do so or are supervised by our developers." }, + "WelcomePage": { + "subtitle": "Choose from the tools below and create your own rail map.", + "rmg": "Create single-line route diagrams and passenger information graphics like those above platform screen doors. Good for clearly showing a line's stations, transfers, directions, first and last trains, and more.", + "rmp": "Freely draw a complete rail transit network map. Good for designing urban metro networks, fictional lines, or organizing multiple lines into one clear map.", + "rma": "Generate station announcement content for a route. Good for turning station, transfer, and direction information into announcement copy that feels closer to a real ride experience.", + "rsg": "Create wayfinding signs and directional information for metro stations. Good for generating directions, station information boards, and display content with the Beijing Subway visual style." + }, + + "Expand": "Expand", + "Collapse": "Collapse", "RMP_CLOUD": "Rail Map Painter・Proceed Signal", "RMP_EXPORT": "Rail Map Painter・Stop and Proceed" -} \ No newline at end of file +} diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index f2a33cc..83108f3 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -23,11 +23,21 @@ "text4": "ご不明な場合、または当社の開発者の指示なしに操作している場合は、このダイアログを閉じてください。" }, + "WelcomePage": { + "subtitle": "以下のツールから選んで、自分だけの路線図を作成しましょう。", + "rmg": "ホームドア上部にあるような単一路線図と旅客案内図を作成できます。1つの路線の駅、乗換、方面、始発・終電などの情報を分かりやすく示すのに適しています。", + "rmp": "鉄道交通ネットワーク全体の路線図を自由に描けます。都市の地下鉄ネットワーク、架空路線の設計、または複数路線を1枚の分かりやすい地図に整理する用途に適しています。", + "rma": "路線向けの駅案内放送文を生成できます。駅、乗換、方面の情報を、実際の乗車体験に近い放送原稿として整理するのに適しています。", + "rsg": "地下鉄駅内の案内サインや誘導情報を作成できます。方向案内、駅情報板、北京地下鉄のビジュアルスタイルを持つ表示内容の生成に適しています。" + }, + "Session expired.": "セッションの有効期限が切れました。", "Please log in again.": "再度ログインしてください。", "Error": "失敗", "Success": "成功", "Close": "閉じる", + "Expand": "展開", + "Collapse": "折りたたむ", "About": "概要", "Allow cookies to help improve our website": "Cookie を許可して網頁の改善にご協力ください", diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index c3d975d..a7d01cc 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -23,11 +23,21 @@ "text4": "확실하지 않거나 당사 개발자의 안내 없이 작업하는 경우 이 대화 상자를 닫으십시오." }, + "WelcomePage": { + "subtitle": "아래 도구 중에서 선택해 나만의 노선도를 만들어 보세요.", + "rmg": "승강장 스크린도어 위에 있는 것 같은 단일 노선도와 승객 안내 이미지를 만들 수 있습니다. 한 노선의 역, 환승, 방향, 첫차와 막차 등의 정보를 명확하게 보여주기에 적합합니다.", + "rmp": "완전한 철도 교통망 지도를 자유롭게 그릴 수 있습니다. 도시 지하철 네트워크, 가상 노선 설계, 또는 여러 노선을 하나의 명확한 지도로 정리하는 데 적합합니다.", + "rma": "노선의 역 안내 방송 내용을 생성할 수 있습니다. 역, 환승, 방향 정보를 실제 승차 경험에 가까운 방송 문안으로 정리하는 데 적합합니다.", + "rsg": "지하철역 안의 안내 표지와 방향 안내 정보를 만들 수 있습니다. 방향 안내, 역 정보 표지, 베이징 지하철 시각 스타일의 표시 콘텐츠를 생성하는 데 적합합니다." + }, + "Session expired.": "세션이 만료되었습니다.", "Please log in again.": "다시 로그인해주세요.", "Error": "오류", "Success": "성공", "Close": "닫기", + "Expand": "펼치기", + "Collapse": "접기", "About": "정보", "Allow cookies to help improve our website": "쿠키를 허용하여 웹사이트 개선에 도움을 주세요", diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index d9d37fc..9310312 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -23,11 +23,21 @@ "text4": "如果您不确定,或者不是在我们的开发者的指引下操作,请关闭这个对话框。" }, + "WelcomePage": { + "subtitle": "从下列工具中选择并创建属于你的线路图吧", + "rmg": "制作站台门上方那样的单线线路图和乘客信息图。适合清楚展示一条线路的站点、换乘、方向和首末班车等信息。", + "rmp": "自由绘制完整的轨道交通线网图。适合设计城市地铁网络、虚构线路,或把多条线路整理成一张清晰的地图。", + "rma": "为线路生成报站内容。适合把站点、换乘和方向信息整理成更像真实乘车体验的播报文案。", + "rsg": "制作地铁站里的导视牌和指示信息。适合生成方向指引、站点信息牌,以及带有北京地铁视觉风格的展示内容。" + }, + "Session expired.": "会话已过期。", "Please log in again.": "请重新登录。", "Error": "错误", "Success": "成功", "Close": "关闭", + "Expand": "展开", + "Collapse": "收起", "About": "关于", "Allow cookies to help improve our website": "允许用 Cookies 帮助改进本网站", diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index 82280d9..e7ba6f0 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -23,11 +23,21 @@ "text4": "若你未能確定,或不是在我們的開發人員的指引下操作,請關閉這個視窗。" }, + "WelcomePage": { + "subtitle": "從下列工具中選擇並建立屬於你的路綫圖吧", + "rmg": "製作月台幕門上方那樣的單綫路綫圖和乘客資訊圖。適合清楚展示一條路綫的車站、轉乘、方向和首末班車等資訊。", + "rmp": "自由繪製完整的軌道交通綫網圖。適合設計城市地鐵網絡、虛構路綫,或把多條路綫整理成一張清晰的地圖。", + "rma": "為路綫生成報站內容。適合把車站、轉乘和方向資訊整理成更像真實乘車體驗的廣播文案。", + "rsg": "製作地鐵站裏的導視牌和指示資訊。適合生成方向指引、車站資訊牌,以及帶有北京地鐵視覺風格的展示內容。" + }, + "Session expired.": "工作階段已過期。", "Please log in again.": "請重新登入。", "Error": "錯誤", "Success": "成功", "Close": "關閉", + "Expand": "展開", + "Collapse": "收起", "About": "關於", "Allow cookies to help improve our website": "容許以 Cookies 幫助改進本網站",