From d7b5b81b5d6ec55d0847b5171c3800a8f7b5c001 Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Tue, 7 Oct 2025 17:25:43 -0700 Subject: feat: add i18n translation (initial JP and EN) --- site/src/components/GameNotes.tsx | 142 +++++++++++++------------------ site/src/components/LanguageSwitcher.tsx | 109 ++++++++++++++++++++++++ site/src/components/NewsFeed.tsx | 14 +-- site/src/components/TitleBar.tsx | 10 ++- site/src/i18n.ts | 30 +++++++ site/src/main.tsx | 1 + site/src/pages/GameSelector.tsx | 11 +-- site/src/pages/Homepage.tsx | 21 ++--- site/src/pages/NotFound.tsx | 10 ++- site/src/utils.ts | 13 +++ 10 files changed, 247 insertions(+), 114 deletions(-) create mode 100644 site/src/components/LanguageSwitcher.tsx create mode 100644 site/src/i18n.ts (limited to 'site/src') diff --git a/site/src/components/GameNotes.tsx b/site/src/components/GameNotes.tsx index b9ab8c6..999bf7b 100644 --- a/site/src/components/GameNotes.tsx +++ b/site/src/components/GameNotes.tsx @@ -5,17 +5,16 @@ import { AimeIntlMaintenanceInfo, AllnetPrivateServerWarning, } from "./NoteModals"; +import i18next from 'i18next'; export const GameNotes = (isMoe: boolean): Record => ({ sdvx: ( <>
@@ -23,20 +22,16 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), iidx: ( <>
    -
  • - • [USA] Certain e-amusement features such as video upload - unavailable{" "} -
  • +
  • {i18next.t('gamenotes.iidx.features')}
@@ -44,10 +39,9 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -59,10 +53,9 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -74,10 +67,9 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -89,10 +81,9 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -104,7 +95,7 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service only in Japan. + {i18next.t('gamenotes.polaris_chord.online_note')}

), @@ -116,12 +107,11 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official e-amusement service in NA available only at Round1 USA + {i18next.t('gamenotes.common.na_service_note')}
- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}
- Note that USA GOLD cabinets follow Japanese daily maintenance schedule. + {i18next.t('gamenotes.ddr.maintenance_note')}

), @@ -130,14 +120,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Online only in Japan and Asia regions. No online service in the US (only - old versions running offline-kit) + {i18next.t('gamenotes.jubeat.online_note')}

- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -146,14 +134,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Online only in Japan and Asia regions. Japan and Asia only. No online - service in the US (only old versions running offline-kit) + {i18next.t('gamenotes.popn_music.online_note')}

- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -162,14 +148,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Online only in Japan and Asia regions. Japan and Asia only. No online - service in the US + {i18next.t('gamenotes.nostalgia.online_note')}

- Online Cabinets in non-supported regions (CAN/EU/AUS) are on private - networks which run older data + {i18next.t('gamenotes.common.private_network_note')}

), @@ -178,12 +162,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- This version of the game is only available in Japan + {i18next.t('gamenotes.common.japan_only_note')}

- You may be on the International version if you are outside of Japan + {i18next.t('gamenotes.common.international_note')}

), @@ -192,12 +176,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- This version of the game is only available in Japan + {i18next.t('gamenotes.common.japan_only_note')}

- You may be on the International version if you are outside of Japan + {i18next.t('gamenotes.common.international_note')}

), @@ -206,12 +190,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official service only in Japan. No International Version + {i18next.t('gamenotes.ongeki_jp.japan_only')}

- You are on a private network if the cabinet is not in Japan + {i18next.t('gamenotes.ongeki_jp.private_network')}

@@ -223,32 +207,29 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Official service only in Japan. No International Version + {i18next.t('gamenotes.idac.japan_only')}

- You are on a private network if the cabinet is not in Japan + {i18next.t('gamenotes.idac.private_network')}

), chunithm_intl: ( <>
    -
  • - • Updates behind JP version. International and JP are completely - seperated -
  • +
  • {i18next.t('gamenotes.chunithm_intl.updates')}

- No official service in NA or EU.{" "} + {i18next.t('gamenotes.chunithm_intl.no_service')}{" "} - See supported regions here + {i18next.t('gamenotes.chunithm_intl.regions_link')}

@@ -262,29 +243,24 @@ export const GameNotes = (isMoe: boolean): Record => ({
    -
  • - • Updates behind JP version. International and JP are completely - seperated -
  • -
  • - • Certain charts are removed from USA region -
  • +
  • {i18next.t('gamenotes.maimaidx_intl.updates')}
  • +
  • {i18next.t('gamenotes.maimaidx_intl.charts')}

- Official service in USA/CAN/ASIA{" "} + {i18next.t('gamenotes.maimaidx_intl.service')}{" "} - See supported regions here + {i18next.t('gamenotes.maimaidx_intl.regions_link')}

- (No official service in EU) + {i18next.t('gamenotes.maimaidx_intl.no_eu')}

@@ -299,7 +275,7 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Online service available only at Round1 Japan and Round1 USA locations + {i18next.t('gamenotes.music_diver.online_service')}

), @@ -311,7 +287,7 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Online service in USA only at Round1 locations + {i18next.t('gamenotes.street_fighter.online_service')}

), @@ -320,14 +296,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- WACCA PLUS is a community continuation of WACCA REVERSE after online - services ended in 2022 + {i18next.t('gamenotes.wacca_plus.community')}

- Runs on Mythos networked cabs. Not all cabinets have WACCA PLUS as these - updates are opt-in by operators. + {i18next.t('gamenotes.wacca_plus.note')}

), @@ -336,19 +310,18 @@ export const GameNotes = (isMoe: boolean): Record => ({

- MÚSECA PLUS is a fan continuation project for MÚSECA 1+1/2. + {i18next.t('gamenotes.museca_plus.community')}

- Runs on various e-amusement private networks. Not all cabinets have - MÚSECA PLUS as it is opt-in. + {i18next.t('gamenotes.museca_plus.note')}

- You can also download it as a data_mod + {i18next.t('gamenotes.museca_plus.download')}

@@ -358,19 +331,17 @@ export const GameNotes = (isMoe: boolean): Record => ({

- A continuation of the abandoned iOS version of REFLEC BEAT (REFLEC BEAT - plus) + {i18next.t('gamenotes.rb_deluxe_plus.community')}

- Needs to be sideloaded once you get a hold of the IPA. Network features - supported. iOS ONLY + {i18next.t('gamenotes.rb_deluxe_plus.note')}

- *Not in main feed as date data is unavailable from this source + {i18next.t('gamenotes.rb_deluxe_plus.feed_note')}

), @@ -379,12 +350,13 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Information below only applies to the latest version of the game (LCD + Banapassport Reader) + {i18next.t('gamenotes.taiko.version_note')}

- Maintenance time is 1am - 7am JST (i think?)
Applies to USA cabs as well (9am - 3pm PST) + {i18next.t('gamenotes.taiko.maintenance')}
+ {i18next.t('gamenotes.taiko.usa_note')}

), @@ -393,12 +365,12 @@ export const GameNotes = (isMoe: boolean): Record => ({

- Singular news feed for NA, ASIA/OCE, and JPN + {i18next.t('gamenotes.wmmt.feed')}

- All regions run different versions of the game + {i18next.t('gamenotes.wmmt.version')}

), diff --git a/site/src/components/LanguageSwitcher.tsx b/site/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..8cceb35 --- /dev/null +++ b/site/src/components/LanguageSwitcher.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { useRef, useState, useEffect } from 'react'; + +const languages = [ + { code: 'en', name: 'English' }, + { code: 'ja', name: '日本語' } +]; + +interface LanguageSwitcherProps { + variant?: 'compact' | 'standard'; +} + +function LanguageSwitcher({ variant = 'standard' }: LanguageSwitcherProps) { + const { i18n } = useTranslation(); + const [searchParams] = useSearchParams(); + const isMoe = searchParams.has("moe"); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0]; + + // Close the dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+ + + {isOpen && ( +
+
+ {languages.map((lang) => ( + + ))} +
+
+ )} +
+ ); +} + +export default LanguageSwitcher; diff --git a/site/src/components/NewsFeed.tsx b/site/src/components/NewsFeed.tsx index 7cf5a08..2e08b16 100644 --- a/site/src/components/NewsFeed.tsx +++ b/site/src/components/NewsFeed.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { getGameTitle, getShortenedGameName } from "../utils.ts"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; export interface NewsData { date: string; @@ -24,6 +25,7 @@ interface NewsFeedProps { } export const NewsFeed: React.FC = ({ newsItems }) => { + const { t } = useTranslation(); const [showEnglish, setShowEnglish] = useState>({}); const [expanded, setExpanded] = useState>({}); const [currentImageIndex, setCurrentImageIndex] = useState< @@ -168,7 +170,7 @@ export const NewsFeed: React.FC = ({ newsItems }) => { onClick={() => toggleLanguage(newsId)} className={`${isMoe ? "bg-pink-200 hover:bg-pink-300" : "bg-gray-800 hover:bg-gray-700"} text-xs py-1 px-2 rounded`} > - {isEnglish ? "View Original" : "View in English"} + {isEnglish ? t("view_in_original_text") : t("view_in_english_text")} )}
@@ -233,13 +235,13 @@ export const NewsFeed: React.FC = ({ newsItems }) => { : `${window.location.origin}${pathname === "/news" ? "" : pathname}#${newsId}`; navigator.clipboard.writeText(url); alert( - "Copied Direct Link to Post (Older news are automatically culled after some time)", + `${t('copy_link_notif')}` ); }} title="Copy permalink" className="text-xs text-blue-400 hover:underline cursor-pointer" > - 🔗 Copy Link to Post + 🔗 {`${t('copy_link_to_post')}`}
@@ -248,8 +250,7 @@ export const NewsFeed: React.FC = ({ newsItems }) => {
- The information above is written by AI / - 上記の情報はAIによって生成されました。 + {`${t('ai_summary_note')}`}
)} @@ -258,8 +259,7 @@ export const NewsFeed: React.FC = ({ newsItems }) => {
- The information above is machine translated and may contain - inaccuracies + {`${t('machine_tl_note')}`}
)} diff --git a/site/src/components/TitleBar.tsx b/site/src/components/TitleBar.tsx index 2229a45..3822980 100644 --- a/site/src/components/TitleBar.tsx +++ b/site/src/components/TitleBar.tsx @@ -5,12 +5,15 @@ import { useNavigate, useLocation, } from "react-router-dom"; +import LanguageSwitcher from "./LanguageSwitcher"; +import { useTranslation } from "react-i18next"; const TitleBar: React.FC = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); const isMoe = searchParams.has("moe"); + const { t } = useTranslation(); const toggleTheme = () => { const params = new URLSearchParams(searchParams); @@ -56,7 +59,7 @@ const TitleBar: React.FC = () => { onClick={toggleTheme} className={`text-sm ${isMoe ? "bg-pink-100 text-pink-800 hover:bg-pink-200 hover:text-pink-600" : "bg-gray-800 text-gray-300 hover:bg-gray-700 hover:text-white"} font-medium px-3 py-1 rounded`} > - {isMoe ? "🌙 Dark" : "🌸 Light"} + {isMoe ? "🌙 "+t('dark_theme_text') : "🌸 "+t("light_theme_text")} { to={`/${isMoe ? "?moe" : ""}`} className={`${isMoe ? "text-pink-800 hover:text-pink-600" : "text-gray-300 hover:text-white"} font-medium text-sm sm:text-base`} > - Main News Feed + {t('news_feed')} - Game Selector + {t('game_selector')} +
diff --git a/site/src/i18n.ts b/site/src/i18n.ts new file mode 100644 index 0000000..9bf1aee --- /dev/null +++ b/site/src/i18n.ts @@ -0,0 +1,30 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import Backend from 'i18next-http-backend'; +import { updateHtmlLang } from './utils'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + + interpolation: { + escapeValue: false, + } + }); + +// Set the HTML lang attribute when language changes +i18n.on('languageChanged', (lng) => { + updateHtmlLang(lng); +}); + +// Initialize HTML lang with the current language +updateHtmlLang(i18n.language); + +export default i18n; diff --git a/site/src/main.tsx b/site/src/main.tsx index 9a1f365..07f6249 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -4,6 +4,7 @@ import './index.css' import { BrowserRouter } from "react-router-dom"; import { Analytics } from "@vercel/analytics/react" import App from './App.tsx' +import './i18n.ts' createRoot(document.getElementById('root')!).render( diff --git a/site/src/pages/GameSelector.tsx b/site/src/pages/GameSelector.tsx index 41551bc..5586cbf 100644 --- a/site/src/pages/GameSelector.tsx +++ b/site/src/pages/GameSelector.tsx @@ -1,5 +1,6 @@ import { Link, useSearchParams } from "react-router-dom"; import TitleBar from "../components/TitleBar"; +import { useTranslation } from "react-i18next"; interface GameCategory { name: string; @@ -67,18 +68,19 @@ const gameInfo: GameCategory[] = [ const GameSelector = () => { const [searchParams] = useSearchParams(); const isMoe = searchParams.has("moe"); + const { t } = useTranslation(); const renderCategory = (category: GameCategory) => (

- {category.name} + {t(`gameselector.categories.${category.name.toLowerCase().replace(' ', '_')}`)}

- {category.description} + {category.name === "COMMUNITY" ? t('gameselector.community_description') : category.description}

{category.games.map((game) => ( @@ -104,13 +106,12 @@ const GameSelector = () => {

- Select a Game + {t('gameselector.title')}

- Individual game feeds keep a longer history of news relating to that - game than the main feed. + {t('gameselector.subtitle')}

{gameInfo.map(renderCategory)}
diff --git a/site/src/pages/Homepage.tsx b/site/src/pages/Homepage.tsx index 4e04688..13ec4fd 100644 --- a/site/src/pages/Homepage.tsx +++ b/site/src/pages/Homepage.tsx @@ -5,6 +5,7 @@ import { getGameTitle } from "../utils.ts"; import TitleBar from "../components/TitleBar"; import { GameNotes } from "../components/GameNotes"; import NotificationButton from "../components/NotificationButton"; +import { useTranslation } from "react-i18next"; interface ArcadeNewsAPIData { fetch_time: number; @@ -12,6 +13,8 @@ interface ArcadeNewsAPIData { } export default function Home() { + const { t } = useTranslation(); + const { gameId } = useParams<{ gameId?: string }>(); const [searchParams] = useSearchParams(); const isMoe = searchParams.has("moe"); @@ -116,7 +119,7 @@ export default function Home() {

- News Not Found + {t('news_not_found')}

- Return Home + {t('return_home')}

@@ -218,7 +221,7 @@ export default function Home() {
-

Welcome to 573-UPDATES

+

{t('homepage.welcome')}

- News and Information for various arcade games is aggregated - here! + {t('homepage.news_aggregation_note')}

- RSS feeds are available on each game's individual page + {t('homepage.rss_feeds')}

- Please see the{" "} + {t('homepage.github_info').split('GitHub')[0]}{" "} GitHub {" "} - for API information + {t('homepage.github_info').split('GitHub')[1] || ''}

@@ -267,8 +269,7 @@ export default function Home() { } flex items-center gap-1 mx-auto transition-colors`} > - Subscribed to {subscribedGames.length} game - {subscribedGames.length !== 1 ? "s" : ""} + {`${t('subscribed_to_games_count')}`} {subscribedGames.length} {`${t('games')}`}

404

-

Page Not Found

+

{t('notFound.title')}

- The page you're looking for doesn't exist or has been moved. + {t('notFound.description')}

diff --git a/site/src/utils.ts b/site/src/utils.ts index c6d0566..e6d19b6 100644 --- a/site/src/utils.ts +++ b/site/src/utils.ts @@ -65,3 +65,16 @@ export const getShortenedGameName = (gameId: string) => { if(lowerCaseGameId.startsWith("wangan_maxi_asia_oce")) return "wangan_maxi_asia_oce"; return gameId.toUpperCase(); }; + +export const updateHtmlLang = (language: string): void => { + document.documentElement.lang = language; + const metaLang = document.querySelector('meta[name="language"]'); + if (metaLang) { + metaLang.setAttribute('content', language); + } else { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'language'); + meta.setAttribute('content', language); + document.head.appendChild(meta); + } +}; -- cgit v1.2.3