diff --git a/index.html b/index.html index e0ec521..b1a537a 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ + + + RailLOOP diff --git a/package.json b/package.json index 3620aba..3143b5a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/postbuild.js", "lint": "eslint .", "preview": "vite preview" }, @@ -26,7 +26,7 @@ "react-hot-toast": "^2.6.0", "react-i18next": "^17.0.2", "react-markdown": "^10.1.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.14.1", "remark-gfm": "^4.0.1", "sweetalert2": "^11.26.24", "typescript": "^5.9.3", @@ -53,4 +53,4 @@ "vite": "^7.2.4", "vite-plugin-html": "^3.2.2" } -} +} \ No newline at end of file diff --git a/scripts/postbuild.js b/scripts/postbuild.js new file mode 100644 index 0000000..b3e351a --- /dev/null +++ b/scripts/postbuild.js @@ -0,0 +1,199 @@ +import fs from 'fs'; +import path from 'path'; + +const distDir = path.resolve(process.cwd(), 'dist'); +const indexHtmlPath = path.join(distDir, 'index.html'); +const publicDir = path.resolve(process.cwd(), 'public'); + +if (!fs.existsSync(indexHtmlPath)) { + console.error("dist/index.html not found. Build may have failed."); + process.exit(1); +} + +const baseHtml = fs.readFileSync(indexHtmlPath, 'utf-8'); + +const LOCALES = [ + { + code: 'zh-cn', + title: 'RailLOOP - 铁路行程记录与分析', + description: 'RailLOOP 是一个强大的铁路行程记录工具。它能帮助您可视化乘车路线、统计里程、发现新站点并与好友分享您的铁路旅程。', + keywords: '铁路, 行程记录, 地图, 里程统计, 铁道迷, RailLOOP, 车站', + lang: 'zh-CN', + localePath: 'zh-CN' + }, + { + code: 'zh-tw', + title: 'RailLOOP - 鐵路行程記錄與分析', + description: 'RailLOOP 是一個強大的鐵路行程記錄工具。它能幫助您視覺化乘車路線、統計里程、發現新站點並與好友分享您的鐵路旅程。', + keywords: '鐵路, 行程記錄, 地圖, 里程統計, 鐵道迷, RailLOOP, 車站', + lang: 'zh-TW', + localePath: 'zh-TW' + }, + { + code: 'en', + title: 'RailLOOP - Railway Trip Logger & Map', + description: 'RailLOOP is a powerful tool to log and visualize your railway trips. Track your mileage, discover new stations, and share your rail adventures with friends.', + keywords: 'railway, trip logger, map, mileage tracker, railfan, RailLOOP, stations, transit', + lang: 'en', + localePath: 'en' + }, + { + code: 'ja-jp', + title: 'RailLOOP - 鉄道乗車記録&マップ', + description: 'RailLOOPは強力な鉄道乗車記録ツールです。乗車ルートの可視化、走行距離の統計、新しい駅の発見、友人との旅の共有をサポートします。', + keywords: '鉄道, 乗車記録, マップ, 走行距離, 鉄道ファン, RailLOOP, 駅', + lang: 'ja-JP', + localePath: 'ja-JP' + } +]; + +const baseUrl = 'https://rail.s3xyseia.xyz'; + +function buildHeadTags(locale) { + let tags = ` + ${locale.title} + + +`; + + LOCALES.forEach(loc => { + tags += ` \n`; + }); + tags += ` \n`; + + const ldJson = { + "@context": "https://schema.org", + "@type": "WebSite", + "name": "RailLOOP", + "url": baseUrl, + "description": locale.description, + "inLanguage": locale.lang + }; + + tags += ` \n`; + return tags; +} + +// 1. Extract Latest Changelog +let changelogText = ''; +try { + const changelogData = JSON.parse(fs.readFileSync(path.join(publicDir, 'changelog.json'), 'utf-8')); + if (changelogData.logs && changelogData.logs.length > 0) { + const latest = changelogData.logs[0]; + changelogText = `

Latest Update: Version ${latest.version} (${latest.date})

${latest.content}

`; + } +} catch (e) { + console.warn("Failed to parse changelog.json", e.message); +} + +// 2. Extract Company Data +let companiesText = ''; +try { + const companyData = JSON.parse(fs.readFileSync(path.join(publicDir, 'company_data.json'), 'utf-8')); + companiesText = `

Supported Companies

`; +} catch (e) { + console.warn("Failed to parse company_data.json", e.message); +} + +// 3. Extract GeoJSON hierarchy +let geojsonHierarchyHtml = '

Railway Network

'; + +// Generate for each locale +LOCALES.forEach(locale => { + const localeDir = path.join(distDir, locale.code); + if (!fs.existsSync(localeDir)) { + fs.mkdirSync(localeDir, { recursive: true }); + } + + const headTags = buildHeadTags(locale); + + let localizedHtml = baseHtml.replace(/.*?<\/title>/, headTags); + localizedHtml = localizedHtml.replace('<html lang="en"', `<html lang="${locale.lang}"`); + + // 4. Extract Locale JSON + let localeTextHtml = ''; + try { + const localeFilePath = path.join(publicDir, 'locales', locale.localePath, 'translation.json'); + if (fs.existsSync(localeFilePath)) { + const localeData = JSON.parse(fs.readFileSync(localeFilePath, 'utf-8')); + // Flatten locale object to string values + const extractValues = (obj) => { + let values = []; + for (const val of Object.values(obj)) { + if (typeof val === 'string') values.push(val); + else if (typeof val === 'object') values = values.concat(extractValues(val)); + } + return values; + }; + const allStrings = extractValues(localeData); + localeTextHtml = `<h3>App Texts</h3><p>${allStrings.join(' | ')}</p>`; + } + } catch (e) { + console.warn(`Failed to process locale ${locale.localePath}`, e.message); + } + + // Combine SEO block + const seoBlock = ` + <div id="seo-content" style="display:none;" aria-hidden="true"> + <h1>${locale.title}</h1> + <p>${locale.description}</p> + ${changelogText} + ${companiesText} + ${geojsonHierarchyHtml} + ${localeTextHtml} + </div> + `; + + // Inject before closing </body> + localizedHtml = localizedHtml.replace('</body>', `${seoBlock}\n</body>`); + + const outPath = path.join(localeDir, 'index.html'); + fs.writeFileSync(outPath, localizedHtml, 'utf-8'); + console.log(`Generated ${outPath}`); +}); diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index 7674a96..a07b5e5 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -37,10 +37,17 @@ import { useMeta } from './contexts'; import { useTranslation } from 'react-i18next'; import { showAlert, showConfirm } from './utils/alerts'; import i18next from 'i18next'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; + const CURRENT_VERSION = meta["currentVersion"]; export const AppLayout: React.FC = () => { + const { lang } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const { activeTab, user, setModalState, setCompanyDB, setRailwayData, setGeoData, trips, pins, railwayData, geoData, companyDB, setTrips, setPins, folders, badgeSettings, @@ -79,12 +86,27 @@ export const AppLayout: React.FC = () => { const { i18n, t } = useTranslation(); useEffect(() => { - // Only apply language change if store has hydrated and language is different. - // This prevents default 'zh-CN' from overriding detected language before hydration. - if (isHydrated && badgeSettings.language && badgeSettings.language !== i18n.language) { - i18n.changeLanguage(badgeSettings.language); + const supportedLangs = ['zh-cn', 'en', 'ja-jp', 'zh-tw']; + if (isHydrated) { + if (!lang || !supportedLangs.includes(lang.toLowerCase())) { + const defaultLang = badgeSettings.language ? badgeSettings.language.toLowerCase() : 'zh-cn'; + navigate(`/${defaultLang}${location.pathname}${location.search}`, { replace: true }); + } else { + let targetLang = lang; + if (targetLang.toLowerCase() === 'zh-cn') targetLang = 'zh-CN'; + if (targetLang.toLowerCase() === 'zh-tw') targetLang = 'zh-TW'; + if (targetLang.toLowerCase() === 'ja-jp') targetLang = 'ja-JP'; + if (targetLang.toLowerCase() === 'en') targetLang = 'en'; + + if (i18n.language !== targetLang) { + i18n.changeLanguage(targetLang); + } + + // Do not auto-update badgeSettings here, to decouple context from IDB. + // Only manual clicks will trigger saveData and update IDB. + } } - }, [badgeSettings.language, i18n, isHydrated]); + }, [lang, i18n, isHydrated, badgeSettings.language, navigate, location]); const { devMode } = useMeta() as any; const isDraggingRef = useRef(false); const workerRef = useRef<Worker | null>(null); @@ -1113,6 +1135,10 @@ export const AppLayout: React.FC = () => { return ( <DragProvider> + <Helmet> + <html lang={i18n.language} /> + </Helmet> + <div className="flex flex-col h-[100dvh] bg-slate-100 font-sans text-slate-800 overflow-visible"> <Toaster position="top-center" /> <Header diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index b5650b0..b314adb 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -1,7 +1,10 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { X, LogIn, UserPlus, Github, Mail } from 'lucide-react'; import { api } from '../services/api'; import { useStore } from '../store'; +import { useUserData } from '../hooks/useUserData'; +import { useShallow } from 'zustand/react/shallow'; import { useTranslation } from 'react-i18next'; @@ -196,6 +199,12 @@ const renderMarkdown = (text) => { }; export const LoginModal = ({ isOpen, onClose, onLoginSuccess, user }) => { + const navigate = useNavigate(); + const location = useLocation(); + const { saveData } = useUserData(); + const { trips, pins, folders } = useStore(useShallow(state => ({ trips: state.trips, pins: state.pins, folders: state.folders }))); + + const [isRegistering, setIsRegistering] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -362,7 +371,20 @@ export const LoginModal = ({ isOpen, onClose, onLoginSuccess, user }) => { {['zh-CN', 'en', 'ja-JP', 'zh-TW'].map(l => ( <button key={l} - onClick={() => setBadgeSettings({ ...badgeSettings, language: l })} + onClick={() => { + const newSettings = { ...badgeSettings, language: l }; + setBadgeSettings(newSettings); + if (user) { + saveData(user.token, trips, pins, folders, newSettings).catch(console.error); + } + const parts = location.pathname.split('/'); + if (parts.length > 1 && ['zh-cn', 'en', 'ja-jp', 'zh-tw'].includes(parts[1].toLowerCase())) { + parts[1] = l.toLowerCase(); + } else { + parts.splice(1, 0, l.toLowerCase()); + } + navigate(parts.join('/') + location.search, { replace: true }); + }} className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${lang === l ? 'bg-white shadow text-blue-600' : 'text-gray-500 hover:text-gray-700'}`} > {l.toUpperCase()} diff --git a/src/components/modals/GithubCardModal.tsx b/src/components/modals/GithubCardModal.tsx index 7e242c0..0d7c86a 100644 --- a/src/components/modals/GithubCardModal.tsx +++ b/src/components/modals/GithubCardModal.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { X, Github, Eye, EyeOff, Lock, Loader2 } from 'lucide-react'; import { useStore } from '../../store'; import { api } from '../../services/api'; @@ -8,6 +9,9 @@ import { useTranslation } from 'react-i18next'; import { showAlert } from '../../utils/alerts'; export const GithubCardModal: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { isOpen, user, folders, badgeSettings } = useStore(useShallow(state => ({ isOpen: !!state.modals.cardModalUser, user: state.modals.cardModalUser, @@ -64,10 +68,20 @@ export const GithubCardModal: React.FC = () => { const publicFolders = folders.filter(f => f.is_public && f.hash); const onUpdateSettings = (s: any) => { + const langChanged = s.language && s.language !== badgeSettings.language; setBadgeSettings(s); if (user) { saveData(user.token, trips, pins, folders, s).catch((e: any) => showAlert(t('app.saveFail', "保存失败: ") + e.message, '', 'error')); } + if (langChanged) { + const parts = location.pathname.split('/'); + if (parts.length > 1 && ['zh-cn', 'en', 'ja-jp', 'zh-tw'].includes(parts[1].toLowerCase())) { + parts[1] = s.language.toLowerCase(); + } else { + parts.splice(1, 0, s.language.toLowerCase()); + } + navigate(parts.join('/') + location.search, { replace: true }); + } }; return ( diff --git a/src/components/modals/InitialSetupModal.tsx b/src/components/modals/InitialSetupModal.tsx index ccf5ba0..fa7255a 100644 --- a/src/components/modals/InitialSetupModal.tsx +++ b/src/components/modals/InitialSetupModal.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useStore } from '../../store'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useShallow } from 'zustand/react/shallow'; import { MapPin, Globe, ChevronRight, Check } from 'lucide-react'; import { useUserData } from '../../hooks/useUserData'; @@ -452,6 +453,9 @@ const LanguageSVG = () => ( ); export const InitialSetupModal: React.FC<InitialSetupModalProps> = ({ isOpen, onComplete }) => { + const navigate = useNavigate(); + const location = useLocation(); + const { badgeSettings, setBadgeSettings, user, trips, pins, folders } = useStore(useShallow(state => ({ badgeSettings: state.badgeSettings, setBadgeSettings: state.setBadgeSettings, @@ -490,6 +494,15 @@ export const InitialSetupModal: React.FC<InitialSetupModalProps> = ({ isOpen, on const handleLanguageSelect = (langId: string) => { setSelectedLanguage(langId); setStep(2); + + // Instant visual update of app language + const parts = location.pathname.split('/'); + if (parts.length > 1 && ['zh-cn', 'en', 'ja-jp', 'zh-tw'].includes(parts[1].toLowerCase())) { + parts[1] = langId.toLowerCase(); + } else { + parts.splice(1, 0, langId.toLowerCase()); + } + navigate(parts.join('/') + location.search, { replace: true }); }; const handleBack = () => { diff --git a/src/main.jsx b/src/main.jsx index d3485be..01fb7c4 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import './i18n'; import { GlobalProvider } from './GlobalProvider'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { HelmetProvider } from 'react-helmet-async'; import { AppLayout } from './AppLayout'; // Ensure it points to AppLayout import { isMobile } from 'react-device-detect'; @@ -29,7 +31,14 @@ root.render( </div> </div> }> - <AppLayout /> + <HelmetProvider> + <BrowserRouter> + <Routes> + <Route path="/:lang/*" element={<AppLayout />} /> + <Route path="/*" element={<AppLayout />} /> + </Routes> + </BrowserRouter> + </HelmetProvider> </React.Suspense> </GlobalProvider> </React.StrictMode> diff --git a/src/pages/StatsPage.tsx b/src/pages/StatsPage.tsx index e2064db..6ac58ba 100644 --- a/src/pages/StatsPage.tsx +++ b/src/pages/StatsPage.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Github, Folder, TrendingUp, Move, MapPin, Map as MapIcon, Globe } from 'lucide-react'; import { useStore } from '../store'; import { calcDist } from '../core/tripCalculator'; @@ -27,6 +28,9 @@ const CITIES = { }; export const StatsPage: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { trips, railwayData, geoData, user, userProfile, segmentGeometries, companyDB, badgeSettings, setBadgeSettings, pins, folders } = useStore(useShallow(state => ({ @@ -183,9 +187,19 @@ export const StatsPage: React.FC = () => { className="bg-transparent text-gray-700 text-sm font-bold w-full outline-none cursor-pointer" value={badgeSettings.language || 'zh-CN'} onChange={(e) => { - const newSettings = { ...badgeSettings, language: e.target.value }; + const newLang = e.target.value; + const newSettings = { ...badgeSettings, language: newLang }; setBadgeSettings(newSettings); if (user) saveData(user.token, trips, pins, folders, newSettings).catch(console.error); + + // Update URL to match new language while preserving current path (excluding old lang prefix) + const parts = location.pathname.split('/'); + if (parts.length > 1 && ['zh-cn', 'en', 'ja-jp', 'zh-tw'].includes(parts[1].toLowerCase())) { + parts[1] = newLang.toLowerCase(); + } else { + parts.splice(1, 0, newLang.toLowerCase()); + } + navigate(parts.join('/') + location.search, { replace: true }); }} > <option value="zh-CN">简体中文</option> diff --git a/vite.config.js b/vite.config.js index ea93c2d..9344772 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,21 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import Sitemap from 'vite-plugin-sitemap' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss(), + Sitemap({ + hostname: 'https://rail.s3xyseia.xyz', + dynamicRoutes: [ + '/zh-cn', + '/zh-tw', + '/en', + '/ja-jp' + ] + }), ], resolve: { dedupe: ['react', 'react-dom'],