Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google-site-verification" content="" />
<meta name="msvalidate.01" content="" />
<meta name="baidu-site-verification" content="" />
<title>RailLOOP</title>
</head>

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node scripts/postbuild.js",
"lint": "eslint .",
"preview": "vite preview"
},
Expand All @@ -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",
Expand All @@ -53,4 +53,4 @@
"vite": "^7.2.4",
"vite-plugin-html": "^3.2.2"
}
}
}
199 changes: 199 additions & 0 deletions scripts/postbuild.js
Original file line number Diff line number Diff line change
@@ -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 = `
<title>${locale.title}</title>
<meta name="description" content="${locale.description}" />
<meta name="keywords" content="${locale.keywords}" />
`;

LOCALES.forEach(loc => {
tags += ` <link rel="alternate" hreflang="${loc.lang}" href="${baseUrl}/${loc.code}" />\n`;
});
tags += ` <link rel="alternate" hreflang="x-default" href="${baseUrl}" />\n`;

const ldJson = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "RailLOOP",
"url": baseUrl,
"description": locale.description,
"inLanguage": locale.lang
};

tags += ` <script type="application/ld+json">\n${JSON.stringify(ldJson, null, 2)}\n </script>\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 = `<h2>Latest Update: Version ${latest.version} (${latest.date})</h2><p>${latest.content}</p>`;
}
} 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 = `<h3>Supported Companies</h3><ul>` + Object.keys(companyData).map(c => `<li>${c}</li>`).join('') + `</ul>`;
} catch (e) {
console.warn("Failed to parse company_data.json", e.message);
}

// 3. Extract GeoJSON hierarchy
let geojsonHierarchyHtml = '<h3>Railway Network</h3><ul>';
try {
const geojsonDir = path.join(publicDir, 'geojson');
if (fs.existsSync(geojsonDir)) {
const files = fs.readdirSync(geojsonDir).filter(f => f.endsWith('.geojson') || f.endsWith('.json'));

// Structure: company -> line -> stations[]
const hierarchy = {};

files.forEach(file => {
try {
const fileData = JSON.parse(fs.readFileSync(path.join(geojsonDir, file), 'utf-8'));
if (fileData.features) {
fileData.features.forEach(feature => {
const props = feature.properties || {};
const type = props.type;
const name = props.name || 'Unknown';
const comp = props.company || props.operator || 'Unknown Company';

if (type === 'line') {
if (!hierarchy[comp]) hierarchy[comp] = {};
if (!hierarchy[comp][name]) hierarchy[comp][name] = new Set();
} else if (type === 'station') {
const line = props.line || 'Unknown Line';
if (!hierarchy[comp]) hierarchy[comp] = {};
if (!hierarchy[comp][line]) hierarchy[comp][line] = new Set();
hierarchy[comp][line].add(name);
}
});
}
} catch (err) {
console.warn(`Failed to process ${file}`, err.message);
}
});

for (const [comp, lines] of Object.entries(hierarchy)) {
geojsonHierarchyHtml += `<li><strong>${comp}</strong><ul>`;
for (const [line, stations] of Object.entries(lines)) {
geojsonHierarchyHtml += `<li><em>${line}</em>: ${Array.from(stations).join(', ')}</li>`;
}
geojsonHierarchyHtml += `</ul></li>`;
}
}
} catch (e) {
console.warn("Failed to process geojson folder", e.message);
}
geojsonHierarchyHtml += '</ul>';

// 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>.*?<\/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}`);
});
36 changes: 31 additions & 5 deletions src/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion src/components/LoginModal.jsx
Original file line number Diff line number Diff line change
@@ -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';


Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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()}
Expand Down
14 changes: 14 additions & 0 deletions src/components/modals/GithubCardModal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
Loading